Merge pull request #32 from oiov/pwd

Feats: support login with email and password
This commit is contained in:
oiov
2025-06-17 20:57:02 +08:00
committed by GitHub
50 changed files with 759 additions and 218 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- pwd
tags:
- "v*.*.*"
pull_request:

View File

@@ -11,7 +11,7 @@ RUN npm install -g pnpm
COPY . .
RUN pnpm config set registry https://registry.npmmirror.com
# RUN pnpm config set registry https://registry.npmmirror.com
RUN pnpm i --frozen-lockfile

View File

@@ -9,6 +9,9 @@
WR.DO 是一个一站式网络工具平台集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计提供无限制临时邮箱收发管理多域名DNS记录内置网站截图、元数据提取等实用API。完整的管理后台支持用户权限控制和服务配置。
- 官网: [https://wr.do](https://wr.do)
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
## 功能列表
- 🔗 **短链服务**
@@ -73,7 +76,7 @@ WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱
### 使用 Vercel 部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
记得填写必要的环境变量。
@@ -93,9 +96,6 @@ docker compose up -d
git clone https://github.com/oiov/wr.do
cd wr.do
pnpm install
# 在 localhost:3000 上运行
pnpm dev
```
#### 初始化数据库
@@ -105,9 +105,21 @@ pnpm postinstall
pnpm db:push
```
```bash
# 在 localhost:3000 上运行
pnpm dev
```
- 默认账号(管理员)`admin@admin.com`
- 默认密码:`123456`
> 登录后请及时修改密码
#### 管理员初始化
Follow https://localhost:3000/setup
> 此初始化引导在 v1.0.2 版本后, 不再是必要步骤
访问 https://localhost:3000/setup
## 环境变量

View File

@@ -8,6 +8,9 @@
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
- Official website: [https://wr.do](https://wr.do)
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
## Features
- 🔗 **Short Link Service**:
@@ -73,7 +76,7 @@ See step by step installation tutorial at [Quick Start for Developer](https://wr
### Deploy with Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
Remember to fill in the necessary environment variables.
@@ -103,11 +106,6 @@ pnpm install
copy `.env.example` to `.env` and fill in the necessary environment variables.
```bash
# run on localhost:3000
pnpm dev
```
#### Init database
```bash
@@ -115,8 +113,18 @@ pnpm postinstall
pnpm db:push
```
```bash
# run on localhost:3000
pnpm dev
```
- Default admin account`admin@admin.com`
- Default admin password`123456`
#### Setup Admin Panel
> After v1.0.2, this setup guide is not needed anymore
Follow https://localhost:3000/setup
## Environment Variables

View File

@@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
// import { hash } from "crypt";
import { prisma } from "@/lib/db";
import { hashPassword } from "@/lib/utils";
import { userPasswordSchema } from "@/lib/validations/user";
export type FormData = {
password: string;
};
export async function updateUserPassword(userId: string, data: FormData) {
try {
const session = await auth();
if (!session?.user || session?.user.id !== userId) {
throw new Error("Unauthorized");
}
const { password } = userPasswordSchema.parse(data);
await prisma.user.update({
where: {
id: userId,
},
data: {
password: hashPassword(password),
},
});
revalidatePath("/dashboard/settings");
return { status: "success" };
} catch (error) {
// console.log(error)
return { status: "error" };
}
}

View File

@@ -51,7 +51,7 @@ export default function LoginPage() {
200 new account sign-ups each day.
</p> */}
<p className="px-8 text-center text-sm text-muted-foreground">
<p className="px-2 text-center text-sm text-muted-foreground">
{t("By clicking continue, you agree to our")}{" "}
<Link
href="/terms"

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -20,6 +21,7 @@ import { SkeletonSection } from "@/components/shared/section-skeleton";
export default function AppConfigs({}: {}) {
const [isPending, startTransition] = useTransition();
const [loginMethodCount, setLoginMethodCount] = useState(0);
const { data: configs, isLoading } = useSWR<Record<string, any>>(
"/api/admin/configs",
@@ -33,6 +35,16 @@ export default function AppConfigs({}: {}) {
if (!isLoading && configs?.system_notification) {
setNotification(configs.system_notification);
}
// 计算登录方式数量
if (!isLoading) {
let count = 0;
if (configs?.enable_google_oauth) count++;
if (configs?.enable_github_oauth) count++;
if (configs?.enable_liunxdo_oauth) count++;
if (configs?.enable_resend_email_login) count++;
if (configs?.enable_email_password_login) count++;
setLoginMethodCount(count);
}
}, [configs, isLoading]);
const handleChange = (value: any, key: string, type: string) => {
@@ -83,21 +95,27 @@ export default function AppConfigs({}: {}) {
/>
)}
</div>
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div className="space-y-1 text-start leading-none">
<p className="font-medium">{t("Login Methods")}</p>
<p className="text-xs text-muted-foreground">
{t("Select the login methods that users can use to log in")}
</p>
</div>
<Icons.chevronDown className="ml-2 size-4" />
<Icons.chevronDown className="ml-auto mr-2 size-4" />
<Badge>{loginMethodCount}</Badge>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3 rounded-md bg-neutral-100 p-3">
{configs && (
<>
<div className="flex items-center justify-between gap-3">
<p className="text-sm">GitHub OAuth</p>
<p className="flex items-center gap-2 text-sm">
<Icons.github className="size-4" /> GitHub OAuth
</p>
<Switch
defaultChecked={configs.enable_github_oauth}
onCheckedChange={(v) =>
@@ -106,7 +124,10 @@ export default function AppConfigs({}: {}) {
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm">Google OAuth</p>
<p className="flex items-center gap-2 text-sm">
<Icons.google className="size-4" />
Google OAuth
</p>
<Switch
defaultChecked={configs.enable_google_oauth}
onCheckedChange={(v) =>
@@ -115,7 +136,14 @@ export default function AppConfigs({}: {}) {
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm">LinuxDo OAuth</p>
<p className="flex items-center gap-2 text-sm">
<img
src="/_static/images/linuxdo.webp"
alt="linuxdo"
className="size-4"
/>
LinuxDo OAuth
</p>
<Switch
defaultChecked={configs.enable_liunxdo_oauth}
onCheckedChange={(v) =>
@@ -124,7 +152,10 @@ export default function AppConfigs({}: {}) {
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm">{t("Resend Email")}</p>
<p className="flex items-center gap-2 text-sm">
<Icons.resend className="size-4" />
{t("Resend Email")}
</p>
<Switch
defaultChecked={configs.enable_resend_email_login}
onCheckedChange={(v) =>
@@ -132,10 +163,27 @@ export default function AppConfigs({}: {}) {
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p className="flex items-center gap-2 text-sm">
<Icons.pwdKey className="size-4" />
{t("Email Password")}
</p>
<Switch
defaultChecked={configs.enable_email_password_login}
onCheckedChange={(v) =>
handleChange(
v,
"enable_email_password_login",
"BOOLEAN",
)
}
/>
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
@@ -154,7 +202,6 @@ export default function AppConfigs({}: {}) {
/>
)}
</div>
<div className="flex flex-col items-start justify-start gap-3">
<div className="space-y-1 leading-none">
<p className="font-medium">{t("Notification")}</p>

View File

@@ -154,15 +154,15 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
<div className="grid gap-2">
<CardTitle>{t("Subdomain List")}</CardTitle>
<CardDescription className="hidden text-balance sm:block">
{t("Please read the")}{" "}
{t("Before using please read the")}{" "}
<Link
target="_blank"
className="font-semibold text-yellow-600 after:content-['↗'] hover:underline"
href="/docs/dns-records#legitimacy-review"
>
{t("legitimacy review")}
</Link>{" "}
{t("before using")}. {t("See")}{" "}
</Link>
. {t("See")}{" "}
<Link
target="_blank"
className="text-blue-500 hover:underline"

View File

@@ -9,6 +9,7 @@ export default function DashboardSettingsLoading() {
text="Manage account and website settings"
/>
<div className="divide-y divide-muted pb-10">
<SkeletonSection />
<SkeletonSection />
<SkeletonSection />
<SkeletonSection card />

View File

@@ -6,6 +6,7 @@ import { DeleteAccountSection } from "@/components/dashboard/delete-account";
import { DashboardHeader } from "@/components/dashboard/header";
import { UserApiKeyForm } from "@/components/forms/user-api-key-form";
import { UserNameForm } from "@/components/forms/user-name-form";
import { UserPasswordForm } from "@/components/forms/user-password-form";
import { UserRoleForm } from "@/components/forms/user-role-form";
export const metadata = constructMetadata({
@@ -29,6 +30,7 @@ export default async function SettingsPage() {
{user.role === "ADMIN" && (
<UserRoleForm user={{ id: user.id, role: user.role }} />
)}
<UserPasswordForm user={{ id: user.id, name: user.name || "" }} />
<UserApiKeyForm
user={{
id: user.id,

View File

@@ -706,19 +706,21 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<RefreshCwIcon className="size-4" />
)}
</Button>
<Button
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditUrl(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">{t("Add URL")}</span>
</Button>
{action.indexOf("admin") === -1 && (
<Button
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditUrl(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">{t("Add URL")}</span>
</Button>
)}
</div>
</div>

View File

@@ -225,6 +225,9 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
{t("After v1-0-2, this setup guide is not needed anymore")}.
</p>
<p className="flex items-start gap-1">
{" "}
{t(

View File

@@ -23,6 +23,7 @@ export async function GET(req: NextRequest) {
"enable_google_oauth",
"enable_liunxdo_oauth",
"enable_resend_email_login",
"enable_email_password_login",
]);
return Response.json(configs, { status: 200 });

View File

@@ -0,0 +1,45 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { hashPassword, verifyPassword } from "@/lib/utils";
export async function POST(req: NextRequest) {
try {
const { email, password, name } = await req.json();
if (!email || !password) {
return Response.json("email and password is required", { status: 400 });
}
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (!user) {
const newUser = await prisma.user.create({
data: {
name: "",
email,
password: hashPassword(password),
active: 1,
role: "USER",
team: "free",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
});
return Response.json(newUser, { status: 200 });
} else {
const passwordCorrect = verifyPassword(password, user.password || "");
if (passwordCorrect) {
return Response.json(user, { status: 200 });
}
}
return Response.json(null, { status: 400 });
} catch (error) {
console.error("[Auth Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

View File

@@ -2,7 +2,7 @@ import { getMultipleConfigs } from "@/lib/dto/system-config";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
export async function GET() {
try {
const configs = await getMultipleConfigs([
"enable_user_registration",
@@ -12,12 +12,14 @@ export async function GET(req: Request) {
"enable_google_oauth",
"enable_liunxdo_oauth",
"enable_resend_email_login",
"enable_email_password_login",
]);
return Response.json({
google: configs.enable_google_oauth,
github: configs.enable_github_oauth,
linuxdo: configs.enable_liunxdo_oauth,
resend: configs.enable_resend_email_login,
credentials: configs.enable_email_password_login,
registration: configs.enable_user_registration,
});
} catch (error) {

View File

@@ -58,10 +58,10 @@ export async function POST(req: Request) {
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
`No matching zone found for domain: ${record.zone_name}`,
{
status: 400,
statusText: "Invalid domain",
statusText: "Invalid zone name",
},
);
}
@@ -78,6 +78,7 @@ export async function POST(req: Request) {
record.type,
record_name,
record.content,
record.zone_name,
1,
);
if (user_record && user_record.length > 0) {

View File

@@ -87,6 +87,7 @@ export async function POST(req: Request) {
record.type,
record_name,
record.content,
record.zone_name,
1,
);
if (user_record && user_record.length > 0) {

View File

@@ -20,7 +20,6 @@ export async function POST(req: Request) {
const { record: reviewRecord, userId, id } = await req.json();
const record = {
...reviewRecord,
// comment: "Created by wr.do (review mode)",
id,
};
@@ -40,9 +39,11 @@ export async function POST(req: Request) {
record,
);
console.log("[data]", data);
if (!data.success || !data.result?.id) {
return Response.json(data.messages, {
status: 501,
return Response.json(data.errors[0].message, {
status: 503,
});
} else {
const res = await updateUserRecordReview(userId, id, {

View File

@@ -28,14 +28,22 @@ export async function POST(req: Request) {
});
}
const res = await deleteDNSRecord(
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
if (active !== 3) {
const res = await deleteDNSRecord(
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
if (res && res.result?.id) {
if (res && res.result?.id) {
await deleteUserRecord(user.id, record_id, zone_id, active);
return Response.json("success", {
status: 200,
statusText: "success",
});
}
} else {
await deleteUserRecord(user.id, record_id, zone_id, active);
return Response.json("success", {
status: 200,

View File

@@ -13,6 +13,7 @@ export async function POST(req: Request) {
const { id, data } = await req.json();
// TODO: update user pwd
const res = await updateUser(id, {
name: data.name,
email: data.email,

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.1",
"versionName": "1.0.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -1,4 +1,5 @@
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Github from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Resend from "next-auth/providers/resend";
@@ -67,5 +68,29 @@ export default {
},
}),
linuxDoProvider,
Credentials({
name: "Credentials",
credentials: {
name: { label: "name", type: "text" },
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const res = await fetch(
process.env.AUTH_URL + "/api/auth/credentials",
{
method: "POST",
body: JSON.stringify(credentials),
},
);
// console.log("[res]", res);
if (res.ok) {
return res.json();
}
return null;
},
}),
],
} satisfies NextAuthConfig;

View File

@@ -53,7 +53,6 @@ export const {
return session;
},
async jwt({ token }) {
if (!token.sub) return token;

View File

@@ -65,9 +65,7 @@ export function RecordForm({
const [currentRecordType, setCurrentRecordType] = useState(
initData?.type || "CNAME",
);
const [currentZoneName, setCurrentZoneName] = useState(
initData?.zone_name || "wr.do",
);
const [currentZoneName, setCurrentZoneName] = useState(initData?.zone_name);
const [email, setEmail] = useState(initData?.user.email || user.email);
const [allowedRecordTypes, setAllowedRecordTypes] = useState<string[]>([]);
const isAdmin = action.indexOf("admin") > -1;
@@ -83,7 +81,7 @@ export function RecordForm({
} = useForm<FormData>({
resolver: zodResolver(createRecordSchema),
defaultValues: {
zone_name: initData?.zone_name || "wr.do",
zone_name: initData?.zone_name,
type: initData?.type || "CNAME",
ttl: initData?.ttl || 1,
proxied: initData?.proxied || false,
@@ -121,6 +119,7 @@ export function RecordForm({
useEffect(() => {
if (validDefaultDomain) {
setValue("zone_name", validDefaultDomain);
setCurrentZoneName(validDefaultDomain);
}
}, [validDefaultDomain]);
@@ -563,7 +562,13 @@ export function RecordForm({
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? t("Update") : t("Save")}</p>
<p>
{type === "edit"
? initData?.active === 2 && isAdmin
? t("Agree")
: t("Update")
: t("Save")}
</p>
)}
</Button>
)}

View File

@@ -1,6 +1,12 @@
"use client";
import { Dispatch, SetStateAction, useMemo, useTransition } from "react";
import {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useTransition,
} from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
@@ -58,7 +64,6 @@ export function UrlForm({
handleSubmit,
register,
formState: { errors },
getValues,
setValue,
} = useForm<FormData>({
resolver: zodResolver(createUrlSchema),
@@ -67,7 +72,7 @@ export function UrlForm({
target: initData?.target || "",
url: initData?.url || "",
active: initData?.active || 1,
prefix: initData?.prefix || "wr.do",
prefix: initData?.prefix || "",
visible: initData?.visible || 0,
expiration: initData?.expiration || "-1",
password: initData?.password || "",
@@ -96,6 +101,12 @@ export function UrlForm({
return shortDomains[0].domain_name;
}, [shortDomains, initData?.prefix]);
useEffect(() => {
if (validDefaultDomain) {
setValue("prefix", validDefaultDomain);
}
}, [validDefaultDomain]);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateUrl(data);

View File

@@ -68,6 +68,7 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
<input
value={apiKey || "Click to generate your API key"}
disabled
type="password"
className="flex h-9 flex-1 shrink-0 items-center truncate rounded-l-md border border-r-0 border-input bg-transparent px-3 py-2 text-sm"
/>
<CopyButton

View File

@@ -10,21 +10,22 @@ import { toast } from "sonner";
import useSWR from "swr";
import * as z from "zod";
import { siteConfig } from "@/config/site";
import { cn, fetcher } from "@/lib/utils";
import { userAuthSchema } from "@/lib/validations/auth";
import { buttonVariants } from "@/components/ui/button";
import { userAuthSchema, userPasswordAuthSchema } from "@/lib/validations/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/shared/icons";
import { Skeleton } from "../ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {
type?: string;
}
type FormData = z.infer<typeof userAuthSchema>;
type FormData2 = z.infer<typeof userPasswordAuthSchema>;
export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
const {
@@ -34,34 +35,64 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
} = useForm<FormData>({
resolver: zodResolver(userAuthSchema),
});
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const {
register: register2,
handleSubmit: handleSubmit2,
formState: { errors: errors2 },
} = useForm<FormData2>({
resolver: zodResolver(userPasswordAuthSchema),
});
// const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [isLoading, startTransition] = React.useTransition();
const [isGoogleLoading, setIsGoogleLoading] = React.useState<boolean>(false);
const [isGithubLoading, setIsGithubLoading] = React.useState<boolean>(false);
const [isLinuxDoLoading, setIsLinuxDoLoading] =
React.useState<boolean>(false);
const searchParams = useSearchParams();
// const router = useRouter();
const t = useTranslations("Auth");
async function onSubmit(data: FormData) {
setIsLoading(true);
const signInResult = await signIn("resend", {
email: data.email.toLowerCase(),
redirect: false,
callbackUrl: searchParams?.get("from") || "/dashboard",
});
setIsLoading(false);
if (!signInResult?.ok) {
return toast.error("Something went wrong.", {
description: "Your sign in request failed. Please try again.",
startTransition(async () => {
const signInResult = await signIn("resend", {
email: data.email.toLowerCase(),
redirect: false,
callbackUrl: searchParams?.get("from") || "/dashboard",
});
}
return toast.success("Check your email", {
description: "We sent you a login link. Be sure to check your spam too.",
if (!signInResult?.ok) {
toast.error(t("Something went wrong"), {
description: "Your sign in request failed. Please try again.",
});
}
toast.success(t("Check your email"), {
description: `${t("We sent you a login link")}. ${t("Be sure to check your spam too")}.`,
});
});
}
async function onSubmitPwd(data: FormData2) {
startTransition(async () => {
const signInResult = await signIn("credentials", {
name: data.name,
email: data.email,
password: data.password,
redirect: false,
callbackUrl: searchParams?.get("from") || "/dashboard",
});
// console.log("[signInResult]", signInResult);
if (signInResult?.error) {
toast.error(t("Something went wrong"), {
description: `[${signInResult?.error}] ${t("Incorrect email or password")}.`,
});
} else {
toast.success(t("Welcome back!"));
window.location.reload();
// router.push(searchParams?.get("from") || "/dashboard");
}
});
}
@@ -99,12 +130,127 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
);
}
const rendeResend = () =>
loginMethod["resend"] && (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<Button
disabled={
!loginMethod.registration ||
isLoading ||
isGoogleLoading ||
isGithubLoading
}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register"
? t("Sign Up with Email")
: t("Sign In with Email")}
</Button>
</div>
</form>
);
const rendeCredentials = () =>
loginMethod["credentials"] && (
<form onSubmit={handleSubmit2(onSubmitPwd)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register2("email")}
/>
{errors2?.email && (
<p className="px-1 text-xs text-red-600">
{errors2.email.message}
</p>
)}
</div>
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Password
</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
autoCapitalize="none"
autoComplete="password"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register2("password")}
/>
{errors2?.password && (
<p className="px-1 text-xs text-red-600">
{errors2.password.message}
</p>
)}
</div>
<Button
disabled={
!loginMethod.registration ||
isLoading ||
isGoogleLoading ||
isGithubLoading
}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{t("Sign In / Sign Up")}
</Button>
<p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
📢 {t("Unregistered users will automatically create an account")}.
</p>
</div>
</form>
);
return (
<div className={cn("grid gap-3", className)} {...props}>
{!loginMethod.registration && (
<p className="rounded-md border border-dashed bg-muted p-3 text-sm text-muted-foreground">
📢 {t("Administrator has disabled new user registration")}.
</p>
)}
{loginMethod["google"] && (
<button
<Button
variant="outline"
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGoogleLoading(true);
signIn("google");
@@ -123,12 +269,12 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
<Icons.google className="mr-2 size-4" />
)}{" "}
Google
</button>
</Button>
)}
{loginMethod["github"] && (
<button
<Button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
variant="outline"
onClick={() => {
setIsGithubLoading(true);
signIn("github");
@@ -147,12 +293,12 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
<Icons.github className="mr-2 size-4" />
)}{" "}
Github
</button>
</Button>
)}
{loginMethod["linuxdo"] && (
<button
<Button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
variant="outline"
onClick={() => {
setIsLinuxDoLoading(true);
signIn("linuxdo");
@@ -175,56 +321,29 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
/>
)}{" "}
LinuxDo
</button>
</Button>
)}
{(loginMethod["google"] ||
loginMethod["github"] ||
loginMethod["linuxdo"]) &&
loginMethod["resend"] &&
(loginMethod["resend"] || loginMethod["credentials"]) &&
rendeSeparator()}
{loginMethod["resend"] && (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<button
className={cn(buttonVariants(), "mt-3")}
disabled={
!loginMethod.registration ||
isLoading ||
isGoogleLoading ||
isGithubLoading
}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register"
? t("Sign Up with Email")
: t("Sign In with Email")}
</button>
</div>
</form>
{loginMethod["resend"] && loginMethod["credentials"] ? (
<Tabs defaultValue="resend">
<TabsList className="w-full justify-center">
<TabsTrigger value="resend">{t("Email Code")}</TabsTrigger>
<TabsTrigger value="password">{t("Password")}</TabsTrigger>
</TabsList>
<TabsContent value="resend">{rendeResend()}</TabsContent>
<TabsContent value="password">{rendeCredentials()}</TabsContent>
</Tabs>
) : (
<>
{rendeResend()}
{rendeCredentials()}
</>
)}
</div>
);

View File

@@ -0,0 +1,110 @@
"use client";
import { useState, useTransition } from "react";
import {
updateUserPassword,
type FormData,
} from "@/actions/update-user-password";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { userPasswordSchema } from "@/lib/validations/user";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SectionColumns } from "@/components/dashboard/section-columns";
import { Icons } from "@/components/shared/icons";
interface UserPasswordFormProps {
user: Pick<User, "id" | "name">;
}
export function UserPasswordForm({ user }: UserPasswordFormProps) {
const { update } = useSession();
const [updated, setUpdated] = useState(false);
const [isPending, startTransition] = useTransition();
const updateUserPasswordWithId = updateUserPassword.bind(null, user.id);
const t = useTranslations("Setting");
const checkUpdate = (value: string) => {
setUpdated(value !== "");
};
const {
handleSubmit,
register,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userPasswordSchema),
defaultValues: {
password: "",
},
});
const onSubmit = handleSubmit((data) => {
startTransition(async () => {
const { status } = await updateUserPasswordWithId(data);
if (status !== "success") {
toast.error("Something went wrong.", {
description: "Your password was not updated. Please try again.",
});
} else {
await update();
setUpdated(false);
toast.success("Your password has been updated.");
}
});
});
return (
<form onSubmit={onSubmit}>
<SectionColumns
title={t("Your Password")}
description={t("Update your password")}
>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Password">
{t("Password")}
</Label>
<Input
id="Password"
className="flex-1"
size={32}
type="password"
placeholder="••••••••"
{...register("password")}
onChange={(e) => checkUpdate(e.target.value)}
/>
<Button
type="submit"
variant={updated ? "blue" : "disable"}
disabled={isPending || !updated}
className="h-9 w-[67px] shrink-0 px-0 sm:w-[130px]"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{t("Save")}</p>
)}
</Button>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.password && (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.password.message}
</p>
)}
<p className="text-[13px] text-muted-foreground">
{t("At least 6 characters, Max 32 characters")}
</p>
</div>
</SectionColumns>
</form>
);
}

View File

@@ -11,7 +11,7 @@ description: 选择你的部署方式
## 使用 Vercel 部署(推荐)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
## 使用 Docker Compose 部署

View File

@@ -12,7 +12,7 @@ description: Choose your deployment method
## Deploy with Vercel (Recommended)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
Remember to fill in the necessary environment variables.

View File

@@ -12,6 +12,7 @@ description: 简单介绍 WR.DO 部署所需的环境变量
或参考社区优秀部署文档:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
</Callout>
<Steps>

View File

@@ -12,6 +12,7 @@ description: How to install the project.
Or read unofficial deployment tutorials:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
</Callout>
<Steps>
@@ -26,7 +27,7 @@ npx create-next-app wrdo --example "https://github.com/oiov/wr.do"
Or deploy with Vercel :
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Foiov%2Fwr.do)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
<Callout type="warning" twClass="mt-4">
A good way to create your repository, but the deployment will fail because you

View File

@@ -193,27 +193,12 @@ pnpm dev
通过浏览器访问:[http://localhost:3000](http://localhost:3000)
## 7. 设置系统
- 默认账号(管理员)`admin@admin.com`
- 默认密码:`123456`
#### 创建第一个账号并将账号权限更改为 ADMIN
> 登录后请及时修改密码
请按以下步骤操作:
* 1. 通过 [http://localhost:3000/login](http://localhost:3000/login) 注册登录你的第一个账号;
* 2. 通过 [http://localhost:3000/setup](http://localhost:3000/setup) 将账号权限更改为 ADMIN
* 3. 然后根据 **面板引导** 配置系统并添加第一个域名。
![](/_static/docs/setup-1.png)
![](/_static/docs/setup-2.png)
<Callout type="info">
将账号权限更改为 ADMIN 后,你可以刷新页面并访问 http://localhost:3000/admin。
<strong>你必须至少添加一个域名,才能使用短链接、邮件或子域名管理等功能。</strong>
</Callout>
## 8. 部署
## 7. 部署教程
详见:[部署指南](/docs/developer/deploy)
@@ -227,12 +212,4 @@ pnpm dev
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
```
将 `SSL/TLS 加密模式` 更改为 `Full` 模式。
### 如何修改团队计划配额?
通过 team.ts 文件修改:
```bash
https://github.com/oiov/wr.do/tree/main/config/team.ts
```
将 `SSL/TLS 加密模式` 更改为 `Full` 模式。

View File

@@ -182,27 +182,10 @@ pnpm dev
```
Via [http://localhost:3000](http://localhost:3000)
## 7. Setup System
- Default admin account`admin@admin.com`
- Default admin password`123456`
#### Create the first account and Change the account's role to ADMIN
Follow the steps below:
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
- 3. Then follow the **panel guide** to config the system and add the first domain.
![](/_static/docs/setup-1.png)
![](/_static/docs/setup-2.png)
<Callout type="info">
After change the account's role to ADMIN, then you can refresh the website and access http://localhost:3000/admin.
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
</Callout>
## 8. Deploy
## 7. Deploy
See [Deploy Guide](/docs/developer/deploy).
@@ -216,12 +199,4 @@ Via:
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
```
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
### How can I change the team plan quota?
Via team.ts:
```bash
https://github.com/oiov/wr.do/tree/main/config/team.ts
```
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.

View File

@@ -77,9 +77,11 @@ export const createDNSRecord = async (
body: JSON.stringify(record),
});
if (!response.ok) {
throw new Error(`HTTP error status: ${response.status}`);
}
// console.log("response.status", await response.json());
// if (!response.ok) {
// throw new Error(`HTTP error status: ${response.status}`);
// }
const data = await response.json();
return data;
@@ -110,10 +112,6 @@ export const deleteDNSRecord = async (
headers,
});
if (!response.ok) {
throw new Error(`HTTP error status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {

View File

@@ -103,7 +103,7 @@ export async function updateUserRecordReview(
const res = await prisma.userRecord.update({
where: {
record_id,
id,
},
data: {
userId,
@@ -234,6 +234,7 @@ export async function getUserRecordByTypeNameContent(
type: string,
name: string,
content: string,
zone_name: string,
active: number = 1,
) {
return await prisma.userRecord.findMany({
@@ -242,6 +243,7 @@ export async function getUserRecordByTypeNameContent(
type,
// content,
name,
zone_name,
active: {
not: 3,
},

View File

@@ -3,7 +3,10 @@ import { User, UserRole } from "@prisma/client";
import { prisma } from "@/lib/db";
export interface UpdateUserForm
extends Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> {}
extends Omit<
User,
"id" | "createdAt" | "updatedAt" | "emailVerified" | "password"
> {}
export const getUserByEmail = async (email: string) => {
try {

View File

@@ -376,3 +376,24 @@ export function extractHost(url: string): string {
const match = url.match(regex);
return match ? match[1] : "";
}
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
/**
* 验证密码
* @param password 用户输入的密码
* @param storedPassword 数据库中存储的加密密码
* @returns 是否匹配
*/
export function verifyPassword(
password: string,
storedPassword: string,
): boolean {
const [salt, hash] = storedPassword.split(":");
const hashToVerify = crypto.scryptSync(password, salt, 64).toString("hex");
return hash === hashToVerify;
}

View File

@@ -5,6 +5,12 @@ export const userAuthSchema = z.object({
email: z.string().email(),
});
export const userPasswordAuthSchema = z.object({
name: z.string().optional(),
email: z.string().email(),
password: z.string().min(6),
});
export const updateUserSchema = z.object({
email: z.string().email(),
image: z.string(),

View File

@@ -9,4 +9,8 @@ export const userRoleSchema = z.object({
role: z.nativeEnum(UserRole),
});
export const userPasswordSchema = z.object({
password: z.string().min(6).max(32),
});
export const userApiKeySchema = z.object({});

View File

@@ -19,7 +19,8 @@
"Domain Name": "Domain Name",
"Please enter a valid domain name (must be hosted on Cloudflare)": "Please enter a valid domain name (must be hosted on Cloudflare)",
"Or add later": "Or add later",
"Submit": "Submit"
"Submit": "Submit",
"After v1-0-2, this setup guide is not needed anymore": "After v1.0.2, this setup guide is not needed anymore"
},
"List": {
"Short URLs": "Short URLs",
@@ -73,9 +74,8 @@
"per page": "per page",
"Total Subdomains": "Total Subdomains",
"Subdomain List": "Subdomains",
"Please read the": "Please read the",
"Before using please read the": "Before using please read the",
"Legitimacy review": "Legitimacy review",
"before using": "before using",
"See": "See",
"examples": "examples",
"for more usage": "for more usage",
@@ -166,7 +166,8 @@
"Plan name must be unique": "Plan name must be unique",
"Record Types": "Record Types",
"Allowed record types": "Allowed record types",
"use `,` to separate": "use `,` to separate"
"use `,` to separate": "use `,` to separate",
"Agree": "Agree"
},
"Components": {
"Dashboard": "Dashboard",
@@ -334,7 +335,18 @@
"Or continue with": "Or continue with",
"Email": "Email",
"Sign Up with Email": "Sign Up with Email",
"Sign In with Email": "Sign In with Email"
"Sign In with Email": "Sign In with Email",
"Email Code": "Email",
"Password": "Password",
"Sign In / Sign Up": "Sign In / Sign Up",
"Incorrect email or password": "Incorrect email or password",
"Something went wrong": "Something went wrong",
"Check your email": "Check your email",
"We sent you a login link": "We sent you a login link",
"Be sure to check your spam too": "Be sure to check your spam too",
"Welcome back!": "Welcome back!",
"Unregistered users will automatically create an account": "Unregistered users will automatically create an account",
"Administrator has disabled new user registration": "Administrator has disabled new user registration"
},
"System": {
"MENU": "Menu",
@@ -466,6 +478,10 @@
"Set system notification, this will be displayed in the header": "Set system notification, this will be displayed in the header",
"Login Methods": "Login Methods",
"Select the login methods that users can use to log in": "Select the login methods that users can use to log in",
"Resend Email": "Resend Email"
"Resend Email": "Resend Email",
"Email Password": "Email Password",
"Your Password": "Your Password",
"Update your password": "Update your password",
"At least 6 characters, Max 32 characters": "At least 6 characters, Max 32 characters"
}
}

View File

@@ -19,7 +19,8 @@
"Domain Name": "域名",
"Please enter a valid domain name (must be hosted on Cloudflare)": "请输入有效的域名(确保已经托管到 Cloudflare)",
"Or add later": "或稍后添加",
"Submit": "提交"
"Submit": "提交",
"After v1-0-2, this setup guide is not needed anymore": "此初始化引导在 v1.0.2 版本后, 不再是必要步骤"
},
"List": {
"Short URLs": "短链列表",
@@ -73,9 +74,8 @@
"per page": "条/页",
"Total Subdomains": "总计",
"Subdomain List": "子域名列表",
"Please read the": "请阅读",
"Before using please read the": "在使用之前请阅读",
"legitimacy review": "链接合法性审查",
"before using": "在使用之前",
"See": "查看",
"examples": "示例",
"for more usage": "了解更多用法",
@@ -166,7 +166,8 @@
"Plan name must be unique": "计划名称必须唯一",
"Record Types": "DNS 记录类型",
"Allowed record types": "请填写标准的 DNS 记录类型",
"use `,` to separate": "使用 `,` 分隔"
"use `,` to separate": "使用 `,` 分隔",
"Agree": "同意"
},
"Components": {
"Dashboard": "用户面板",
@@ -334,7 +335,18 @@
"Or continue with": "或使用",
"Email": "邮箱",
"Sign Up with Email": "使用邮箱注册",
"Sign In with Email": "使用邮箱登录"
"Sign In with Email": "使用邮箱登录",
"Email Code": "邮箱验证",
"Password": "账号密码",
"Sign In / Sign Up": "点击登录/注册",
"Incorrect email or password": "邮箱或密码错误",
"Something went wrong": "出错了",
"Check your email": "检查您的邮箱",
"We sent you a login link": "我们已向您发送登录链接",
"Be sure to check your spam too": "请确保检查您的垃圾邮件",
"Welcome back!": "欢迎回来!",
"Unregistered users will automatically create an account": "未注册用户将自动创建账户",
"Administrator has disabled new user registration": "管理员已关闭新用户注册"
},
"System": {
"MENU": "菜单",
@@ -466,6 +478,10 @@
"Set system notification, this will be displayed in the header": "设置系统通知,将在网页顶部显示",
"Login Methods": "登录方式",
"Select the login methods that users can use to log in": "选择用户可以使用的登录方式",
"Resend Email": "Resend 邮箱登录"
"Resend Email": "Resend 邮箱登录",
"Email Password": "账号密码登录",
"Your Password": "账号密码",
"Update your password": "更新您的密码",
"At least 6 characters, Max 32 characters": "密码长度至少6位最多32位"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "1.0.1",
"version": "1.0.2",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -75,6 +75,7 @@
"@vercel/analytics": "^1.3.1",
"@vercel/functions": "^1.4.0",
"@vercel/og": "^0.6.2",
"bcrypt": "^6.0.0",
"chalk": "^4.1.1",
"cheerio": "1.0.0-rc.12",
"class-variance-authority": "^0.7.0",
@@ -140,6 +141,7 @@
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.13",
"@types/bcrypt": "^5.0.2",
"@types/lodash": "^4.17.16",
"@types/node": "^20.14.11",
"@types/qrcode": "^1.5.5",

34
pnpm-lock.yaml generated
View File

@@ -158,6 +158,9 @@ importers:
'@vercel/og':
specifier: ^0.6.2
version: 0.6.2
bcrypt:
specifier: ^6.0.0
version: 6.0.0
chalk:
specifier: ^4.1.1
version: 4.1.2
@@ -348,6 +351,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.13
version: 0.5.13(tailwindcss@3.4.6)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/lodash':
specifier: ^4.17.16
version: 4.17.16
@@ -3145,6 +3151,9 @@ packages:
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
'@types/bcrypt@5.0.2':
resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
'@types/conventional-commits-parser@5.0.0':
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
@@ -3808,6 +3817,10 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@@ -6171,6 +6184,14 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-addon-api@8.4.0:
resolution: {integrity: sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
@@ -11105,6 +11126,10 @@ snapshots:
dependencies:
'@types/estree': 1.0.5
'@types/bcrypt@5.0.2':
dependencies:
'@types/node': 20.14.11
'@types/conventional-commits-parser@5.0.0':
dependencies:
'@types/node': 20.14.11
@@ -11926,6 +11951,11 @@ snapshots:
base64id@2.0.0: {}
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.4.0
node-gyp-build: 4.8.4
big.js@5.2.2: {}
binary-extensions@2.2.0: {}
@@ -14885,6 +14915,10 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-addon-api@8.4.0: {}
node-gyp-build@4.8.4: {}
node-releases@2.0.14: {}
node-releases@2.0.18: {}

View File

@@ -0,0 +1,38 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "password" TEXT;
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
'enable_email_password_login',
'true',
'BOOLEAN',
'是否启用邮箱密码登录'
);
INSERT INTO "users"
(
id,
name,
email,
password,
active,
role,
team
)
VALUES
(
'cmadvu9w874j2sczhg174pftq',
'admin',
'admin@admin.com',
'c0025ebe2edf525367e859821ccac33a:95992aad7ca8dc7c51855859f7adaa6282b09439b3e138fde22aeeaa6864af0f43fdd297cc7409b24a011300c038ff4d7585f89019e7629120123ec947f62b15',
1,
'ADMIN',
'free'
);

View File

@@ -61,6 +61,7 @@ model User {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
role UserRole @default(USER)
password String?
accounts Account[]
sessions Session[]

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.1",
"versionName": "1.0.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.1",
"versionName": "1.0.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://wr.do/api/feature</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

File diff suppressed because one or more lines are too long