feat: support setup guide for admin

This commit is contained in:
oiov
2025-05-21 16:16:56 +08:00
parent 8a05fa0907
commit 2369885fda
27 changed files with 593 additions and 126 deletions
+14 -3
View File
@@ -56,11 +56,22 @@ pnpm install
pnpm dev
```
#### 初始化数据库
```bash
pnpm postinstall
pnpm db:push
```
#### 激活管理员面板
Follow https://localhost:3000/setup
## 社区群组
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## 许可证
+14 -3
View File
@@ -57,6 +57,17 @@ pnpm install
pnpm dev
```
#### Init database
```bash
pnpm postinstall
pnpm db:push
```
#### Setup Admin Panel
Follow https://localhost:3000/setup
## Legitimacy review
- To avoid abuse, applications without website content will be rejected
@@ -68,9 +79,9 @@ pnpm dev
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## License
+9 -16
View File
@@ -2,20 +2,13 @@
import { useState } from "react";
import Link from "next/link";
import { Domain, User } from "@prisma/client";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import {
cn,
expirationTime,
fetcher,
removeUrlSuffix,
timeAgo,
} from "@/lib/utils";
import { fetcher, timeAgo } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -23,7 +16,6 @@ import {
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
@@ -81,10 +73,10 @@ export default function DomainList({ user, action }: DomainListProps) {
useState<DomainFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isShowDomainInfo, setShowDomainInfo] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
null,
);
// const [isShowDomainInfo, setShowDomainInfo] = useState(false);
// const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
// null,
// );
const [searchParams, setSearchParams] = useState({
slug: "",
target: "",
@@ -169,7 +161,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</div>
</CardHeader>
<CardContent>
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
{/* <div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
@@ -192,7 +184,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</Button>
)}
</div>
</div>
</div> */}
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
@@ -274,6 +266,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
disabled
defaultChecked={domain.active}
onCheckedChange={(value) =>
handleChangeStatus(value, "active", domain)
+6 -11
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import DashboardScrapeCharts from "./charts";
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
linkText="Open API."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Screenshot"
desc="Take a screenshot of the webpage."
link="/dashboard/scrape/screenshot"
icon="camera"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Meta Info"
desc="Extract website metadata."
link="/dashboard/scrape/meta-info"
icon="globe"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to QR Code"
desc="Generate QR Code from URL."
link="/dashboard/scrape/qrcode"
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Markdown"
desc="Convert website content to Markdown format."
link="/dashboard/scrape/markdown"
icon="heading1"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Text"
desc="Extract website text."
link="/dashboard/scrape/markdown"
+358
View File
@@ -0,0 +1,358 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons";
export default function StepGuide({
user,
}: {
user: { id: string; email: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [isMobile, setIsMobile] = useState(false);
const steps = [
{
id: 1,
title: "Set up an administrator",
description:
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
component: () => <SetAdminRole id={user.id} email={user.email} />,
},
{
id: 2,
title: "Add the first domain",
description:
"Check out your reimagined site and click to Migrate & Download.",
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: "Congrats on completing setup 🎉",
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
component: () => <Congrats />,
},
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
setCurrentStep(currentStep + 1);
if (!completedSteps.includes(currentStep)) {
setCompletedSteps([...completedSteps, currentStep]);
}
} else if (currentStep === steps.length) {
router.push("/admin");
}
};
const goToPreviousStep = () => {
if (currentStep > 1) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
const currentStepData =
steps.find((step) => step.id === currentStep) || steps[0];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
};
return (
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
</span>
<span className="text-muted-foreground">of</span>
<span>{steps.length}</span>
</div>
</div>
{/* Content area */}
<div className="relative w-full rounded-lg">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
<motion.h3
className="text-base font-semibold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.title}
</motion.h3>
</div>
<motion.div
className="h-full"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.component()}
</motion.div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
<motion.div
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<button
onClick={goToPreviousStep}
disabled={currentStep === 1}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
currentStep === 1
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<button
onClick={goToNextStep}
// disabled={currentStep === steps.length}
className={cn(
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? "🚀 Start" : "Next"}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
</Modal>
);
}
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
if (res.ok) {
setIsAdmin(true);
}
});
};
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
Ready
</Badge>
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
Allow Sign Up:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
Set {email} as ADMIN:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
className=""
variant={"outline"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Active Now
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel
and add domain names.
</p>
<p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view
and edit all resources (short links, subdomains, email), etc.
</p>
<p>
📢 Via{" "}
<a
className="text-blue-500"
target="_blank"
href="/docs/developer/quick-start"
>
quick start
</a>{" "}
docs to get more information.
</p>
</div>
</div>
);
}
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/domain", {
method: "POST",
body: JSON.stringify({
data: {
domain_name: removeUrlSuffix(domain),
enable_short_link: true,
enable_email: true,
enable_dns: true,
cf_zone_id: "",
cf_api_key: "",
cf_email: "",
cf_api_key_encrypted: false,
max_short_links: 0,
max_email_forwards: 0,
max_dns_records: 0,
active: true,
},
}),
});
if (res.ok) {
onNextStep();
} else {
toast.error("Created Failed!", {
description: await res.text(),
});
}
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<FormSectionColumns title="Domain Name">
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
Domain Name
</Label>
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare).
</p>
</div>
<div className="mt-2 flex w-full items-center justify-end gap-3">
<Button
className="text-xs text-muted-foreground"
variant={"ghost"}
size={"sm"}
onClick={onNextStep}
>
Or add later
</Button>
<Button
className="flex items-center gap-1"
size={"sm"}
variant={"blue"}
disabled={isPending}
onClick={handleCreateDomain}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Submit
</Button>
</div>
</FormSectionColumns>
</div>
);
}
function Congrats() {
return <></>;
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getAllUsersCount } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import SetupGuide from "./guide";
export const metadata = constructMetadata({
title: "Setup Guide",
description: "Setup Guide",
});
export default async function SetupPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
if (user.role === "ADMIN") redirect("/admin");
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
}
return redirect("/admin");
}
+3 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { FeatureMap, getDomainsByFeature } from "@/lib/dto/domains";
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -22,7 +22,8 @@ export async function GET(req: NextRequest) {
);
}
const domainList = await getDomainsByFeature(FeatureMap[feature]);
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
return Response.json(domainList, { status: 200 });
} catch (error) {
console.error("[Error]", error);
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import {
checkUserStatus,
getAllUsersCount,
setFirstUserAsAdmin,
} from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
const res = await setFirstUserAsAdmin(user.id);
if (res) {
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
}
return Response.json({ admin: false }, { status: 400 });
}
return redirect("/admin");
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}
+1 -1
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": "0.6.0",
"versionName": "0.6.1",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -3
View File
@@ -145,14 +145,12 @@ export function HeroCard({
);
}
export async function ScrapeInfoCard({
userId,
export async function StaticInfoCard({
title,
desc,
link,
icon = "users",
}: {
userId: string;
title: string;
desc?: string;
link: string;
+22 -20
View File
@@ -603,32 +603,34 @@ export default function EmailSidebar({
{isLoadingDomains ? (
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
) : (
emailDomains && (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={
domainSuffix || emailDomains[0].domain_name
}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains.map((v) => (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || "wr.do"}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains && emailDomains.length > 0 ? (
emailDomains.map((v) => (
<SelectItem
key={v.domain_name}
value={v.domain_name}
>
@{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Button
className="rounded-l-none"
+1
View File
@@ -205,6 +205,7 @@ export function DomainForm({
{...register("active")}
defaultChecked={initData?.active ?? true}
onCheckedChange={(value) => setValue("active", value)}
disabled
/>
</div>
</div>
+23 -19
View File
@@ -212,28 +212,32 @@ export function RecordForm({
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
recordDomains && (
<Select
onValueChange={(value: string) => {
setValue("zone_name", value);
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{recordDomains.map((v) => (
<Select
onValueChange={(value: string) => {
setValue("zone_name", value);
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{recordDomains && recordDomains.length > 0 ? (
recordDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
+22 -20
View File
@@ -204,29 +204,31 @@ export function UrlForm({
{isLoading ? (
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
) : (
shortDomains && (
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={
initData?.prefix || shortDomains[0].domain_name
}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{shortDomains.map((v) => (
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || "wr.do"}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{shortDomains && shortDomains.length > 0 ? (
shortDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Input
id="url"
+2
View File
@@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground",
blue: "bg-blue-500 hover:bg-blue-600 border-transparent text-white",
green: "bg-green-500 hover:bg-green-600 border-transparent text-white",
},
},
defaultVariants: {
-1
View File
@@ -54,7 +54,6 @@ Copy/paste the `.env.example` in the `.env` file:
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
For step by step installation, see [Quick Start](/docs/developer/quick-start).
+13 -22
View File
@@ -45,25 +45,23 @@ DATABASE_URL=
### Deploy Postgres
#### Manually install (Recommended)
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations/20240705091917_init/migration.sql),
copy the sql code to the database to initialize the database schema.
#### or
```bash
pnpm postinstall
pnpm db:push
```
#### Or manually init
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations),
copy the sql code to the database to initialize the database schema.
### Add the AUTH_SECRET Environment Variable
The `AUTH_SECRET` environment variable is used to encrypt tokens and email verification hashes(NextAuth.js).
You can generate one from https://generate-secret.vercel.app/32:
```js title=".env"
AUTH_SECRET=a3e686f39b2a878c6866e4604e6f1b1b
AUTH_SECRET=10000032bsfasfafk4lkkfsa
```
## 2. Configure Authentication Service
@@ -197,10 +195,14 @@ Follow the steps below:
- 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.
<Callout type="info">
After change change the account's role to ADMIN, and then you can refresh the website and access http://localhost:3000/admin.
![](/_static/docs/setup-1.png)
<strong>You must add at least one domain to start using short links, email or subdomain management functions.</strong>
![](/_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>
## Q & A
@@ -215,17 +217,6 @@ 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 access the admin panel after first deployment?
You need to first register an account and log in,
and modify the `role` field of this account to `ADMIN` in the `users` table of the database.
Then, refresh the website and access http://localhost:3000/admin.
<Callout type="note">
Although it may not be convenient to do so, this is currently the fastest way to become an administrator.
In future versions, we will implement the function of automatically setting up administrators as soon as possible.
</Callout>
### How can I change the team plan quota?
Via team.ts:
+14
View File
@@ -81,6 +81,20 @@ export async function getDomainsByFeature(
}
}
export async function getDomainsByFeatureClient(feature: string) {
try {
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
domain_name: true,
},
});
return domains;
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function createDomain(data: DomainConfig) {
try {
const createdDomain = await prisma.domain.create({ data });
+11
View File
@@ -85,6 +85,17 @@ export async function getAllUsersCount() {
}
}
export async function setFirstUserAsAdmin(userId: string) {
try {
return await prisma.user.update({
where: { id: userId },
data: { role: UserRole.ADMIN },
});
} catch (error) {
return null;
}
}
export async function getAllUsersActiveApiKeyCount() {
try {
return await prisma.user.count({ where: { apiKey: { not: null } } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "0.6.0",
"version": "0.6.1",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -274,7 +274,7 @@ CREATE TABLE "user_send_emails"
"html" TEXT DEFAULT '',
"replyTo" TEXT DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+1 -1
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": "0.6.0",
"versionName": "0.6.1",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -1
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": "0.6.0",
"versionName": "0.6.1",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -1
View File
File diff suppressed because one or more lines are too long