feat: profile #5
1655
client/bun.lock
1655
client/bun.lock
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@marsidev/react-turnstile": "^1.4.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -29,12 +30,16 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-form": "^1.27.7",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.6",
|
||||
"@tanstack/react-router-devtools": "^1.141.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/zod-adapter": "^1.143.4",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"axios": "^1.13.2",
|
||||
"base-64": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"culori": "^4.0.2",
|
||||
@@ -44,10 +49,13 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"utf8": "^3.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -56,13 +64,16 @@
|
||||
"@antfu/eslint-config": "^6.7.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/base-64": "^1.0.2",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
7943
client/pnpm-lock.yaml
generated
Normal file
7943
client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,20 +38,20 @@ function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean })
|
||||
const { data } = useCheckinCode(eventId, enabled);
|
||||
return data
|
||||
? (
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{data.data.checkin_code}
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{data.data.checkin_code}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<QrSectionSkeleton />
|
||||
);
|
||||
<QrSectionSkeleton />
|
||||
);
|
||||
}
|
||||
|
||||
function QrSectionSkeleton() {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function withFallback<P extends object>(
|
||||
};
|
||||
|
||||
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
|
||||
})`;
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useRef, useState } from 'react';
|
||||
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function LoginForm({
|
||||
oauthParams,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
}: React.ComponentProps<'div'> & {
|
||||
oauthParams: AuthorizeSearchParams;
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
@@ -28,7 +32,7 @@ export function LoginForm({
|
||||
event.preventDefault();
|
||||
const formData = new FormData(formRef.current!);
|
||||
const email = formData.get('email')! as string;
|
||||
mutateAsync({ email, turnstile_token: token! }).then(() => {
|
||||
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
|
||||
void navigate({ to: '/magicLinkSent', search: { email } });
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
112
client/src/components/profile/edit-profile-dialog.tsx
Normal file
112
client/src/components/profile/edit-profile-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import {
|
||||
Input,
|
||||
} from '@/components/ui/input';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
nickname: z.string().min(1),
|
||||
subtitle: z.string().min(1),
|
||||
});
|
||||
export function EditProfileDialog() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
nickname: '',
|
||||
subtitle: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: formSchema,
|
||||
},
|
||||
onSubmit: async ({
|
||||
value,
|
||||
}) => {
|
||||
try {
|
||||
toast(
|
||||
<code className="text-white">{JSON.stringify(value, null, 2)}</code>,
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Form submission error', error);
|
||||
toast.error('Failed to submit the form. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full mt-4" size="lg">编辑个人资料</Button>
|
||||
</DialogTrigger>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="noa@requiem.garden"
|
||||
value={form.getFieldValue('email')}
|
||||
onChange={e => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nickname">昵称</FieldLabel>
|
||||
<Input
|
||||
id="nickname"
|
||||
name="nickname"
|
||||
placeholder="Noa Virellia"
|
||||
value={form.getFieldValue('nickname')}
|
||||
onChange={e => form.setFieldValue('nickname', e.target.value)}
|
||||
/>
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subtitle">副标题</FieldLabel>
|
||||
<Input
|
||||
id="subtitle"
|
||||
name="subtitle"
|
||||
placeholder="天生骄傲"
|
||||
value={form.getFieldValue('subtitle')}
|
||||
onChange={e => form.setFieldValue('subtitle', e.target.value)}
|
||||
/>
|
||||
<FieldError />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
102
client/src/components/profile/edit-profile-form.tsx
Normal file
102
client/src/components/profile/edit-profile-form.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
useForm,
|
||||
} from '@tanstack/react-form';
|
||||
import {
|
||||
toast,
|
||||
} from 'sonner';
|
||||
import {
|
||||
z,
|
||||
} from 'zod';
|
||||
import {
|
||||
Button,
|
||||
} from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import {
|
||||
Input,
|
||||
} from '@/components/ui/input';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string(),
|
||||
nickname: z.string().min(1),
|
||||
subtitle: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function EditProfileForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
nickname: '',
|
||||
subtitle: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: formSchema,
|
||||
},
|
||||
onSubmit: async ({
|
||||
value,
|
||||
}) => {
|
||||
try {
|
||||
toast(
|
||||
<code className="text-white">{JSON.stringify(value, null, 2)}</code>,
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Form submission error', error);
|
||||
toast.error('Failed to submit the form. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="noa@requiem.garden"
|
||||
|
||||
value={form.getFieldValue('email')}
|
||||
onChange={e => form.setFieldValue('email', e.target.value)}
|
||||
/>
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nickname">昵称</FieldLabel>
|
||||
<Input
|
||||
id="nickname"
|
||||
name="nickname"
|
||||
placeholder="Noa Virellia"
|
||||
|
||||
value={form.getFieldValue('nickname')}
|
||||
onChange={e => form.setFieldValue('nickname', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subtitle">副标题</FieldLabel>
|
||||
<Input
|
||||
id="subtitle"
|
||||
name="subtitle"
|
||||
placeholder="天生骄傲"
|
||||
|
||||
value={form.getFieldValue('subtitle')}
|
||||
onChange={e => form.setFieldValue('subtitle', e.target.value)}
|
||||
/>
|
||||
|
||||
<FieldError />
|
||||
</Field>
|
||||
<Button type="submit">提交</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
35
client/src/components/profile/main-profile.tsx
Normal file
35
client/src/components/profile/main-profile.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
import Markdown from 'react-markdown';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { base64ToUtf8 } from '@/lib/utils';
|
||||
import { EditProfileDialog } from './edit-profile-dialog';
|
||||
|
||||
export function MainProfile() {
|
||||
const { data: user } = useUserInfo();
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex w-full flex-row gap-4 mt-2">
|
||||
<Avatar className="size-16 rounded-full border-2 border-muted">
|
||||
<AvatarImage src={user.avatar} alt={user.nickname} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
|
||||
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<EditProfileDialog />
|
||||
<section className="px-2 mt-4">
|
||||
<div className="flex flex-row gap-2 items-center text-sm">
|
||||
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-md border border-muted w-full min-h-72 mt-4 p-6 prose dark:prose-invert max-w-[1012px] self-center">
|
||||
{/* Bio */}
|
||||
<Markdown>{base64ToUtf8(user.bio)}</Markdown>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
IconDashboard,
|
||||
IconSettings,
|
||||
} from '@tabler/icons-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavSecondary } from '@/components/nav-secondary';
|
||||
import { NavMain } from '@/components/sidebar/nav-main';
|
||||
import { NavSecondary } from '@/components/sidebar/nav-secondary';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -16,30 +11,9 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { navData } from '@/lib/navData';
|
||||
import { NavUser } from './nav-user';
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: 'shadcn',
|
||||
email: 'm@example.com',
|
||||
avatar: '/avatars/shadcn.jpg',
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: '工作台',
|
||||
url: '/',
|
||||
icon: IconDashboard,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: '设置',
|
||||
url: '#',
|
||||
icon: IconSettings,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
<NavMain items={navData.navMain} />
|
||||
<NavSecondary items={navData.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import * as React from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -27,12 +28,16 @@ export function NavSecondary({
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { useLogout } from '@/hooks/useLogout';
|
||||
import { withFallback } from './hoc/with-fallback';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { withFallback } from '../hoc/with-fallback';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
function NavUser_() {
|
||||
const { isMobile } = useSidebar();
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { navData } from '@/lib/navData';
|
||||
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||
const allNavItems = [...navData.navMain, ...navData.navSecondary];
|
||||
const currentTitle
|
||||
= allNavItems.find(item =>
|
||||
item.url === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.url),
|
||||
)?.title ?? '工作台';
|
||||
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
@@ -10,7 +21,7 @@ export function SiteHeader() {
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">工作台</h1>
|
||||
<h1 className="text-base font-medium">{currentTitle}</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ function FieldError({
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(async () => {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { QrDialog } from '../checkin/qr-dialog';
|
||||
import { withFallback } from '../hoc/with-fallback';
|
||||
import { CardSkeleton } from './card-skeleton';
|
||||
|
||||
function CheckinCard_() {
|
||||
const { data } = useUserInfo();
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { axiosClient } from '@/lib/axios';
|
||||
|
||||
interface GetMagicLinkPayload {
|
||||
interface GetMagicLinkPayload extends AuthorizeSearchParams {
|
||||
email: string;
|
||||
turnstile_token: string;
|
||||
}
|
||||
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
|
||||
export function useGetMagicLink() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: GetMagicLinkPayload) => {
|
||||
return axiosClient.post<object>('/auth/magic', payload);
|
||||
return axiosClient.post<{ status: string }>('/auth/magic', payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ export function useUserInfo() {
|
||||
nickname: string;
|
||||
subtitle: string;
|
||||
avatar: string;
|
||||
checkin: string | null;
|
||||
bio: string;
|
||||
}
|
||||
>('/user/info');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useLogout() {
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
void navigate({ to: '/login' });
|
||||
void navigate({ to: '/authorize' });
|
||||
}, [navigate]);
|
||||
|
||||
return { logout };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -36,7 +36,7 @@ axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => {
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof AxiosError && e.status === 401) {
|
||||
await router.navigate({ to: '/login' });
|
||||
await router.navigate({ to: '/authorize' });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
21
client/src/lib/navData.ts
Normal file
21
client/src/lib/navData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
IconDashboard,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export const navData = {
|
||||
navMain: [
|
||||
{
|
||||
title: '工作台',
|
||||
url: '/',
|
||||
icon: IconDashboard,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: '个人资料',
|
||||
url: '/profile',
|
||||
icon: IconUser,
|
||||
},
|
||||
],
|
||||
};
|
||||
14
client/src/lib/random.ts
Normal file
14
client/src/lib/random.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generate a cryptographically secure OAuth2 state string
|
||||
* base64url encoded, URL-safe
|
||||
*/
|
||||
export function generateOAuthState(bytes: number = 32): string {
|
||||
const random = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(random);
|
||||
|
||||
// base64url encode
|
||||
return btoa(String.fromCharCode(...random))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
@@ -29,6 +29,12 @@ export function clearTokens() {
|
||||
setRefreshToken('');
|
||||
}
|
||||
|
||||
export async function doSetTokenByCode(code: string) {
|
||||
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
|
||||
setToken(data.access_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
}
|
||||
|
||||
export async function doRefreshToken() {
|
||||
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { ClassValue } from 'clsx';
|
||||
// eslint-disable-next-line unicorn/prefer-node-protocol
|
||||
import { Buffer } from 'buffer';
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function base64ToUtf8(base64: string): string {
|
||||
return new TextDecoder('utf-8').decode(
|
||||
Uint8Array.from(Buffer.from(base64, 'base64')),
|
||||
);
|
||||
}
|
||||
|
||||
export function utf8ToBase64(utf8: string): string {
|
||||
return Buffer.from(utf8, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
@@ -9,19 +9,26 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TokenRouteImport } from './routes/token'
|
||||
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthorizeRouteImport } from './routes/authorize'
|
||||
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
||||
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
||||
|
||||
const TokenRoute = TokenRouteImport.update({
|
||||
id: '/token',
|
||||
path: '/token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
||||
id: '/magicLinkSent',
|
||||
path: '/magicLinkSent',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
const AuthorizeRoute = AuthorizeRouteImport.update({
|
||||
id: '/authorize',
|
||||
path: '/authorize',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
||||
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => SidebarLayoutRoute,
|
||||
} as any)
|
||||
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
getParentRoute: () => SidebarLayoutRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/token': typeof TokenRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/token': typeof TokenRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/token': typeof TokenRoute
|
||||
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/login' | '/magicLinkSent' | '/'
|
||||
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/login' | '/magicLinkSent' | '/'
|
||||
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_sidebarLayout'
|
||||
| '/login'
|
||||
| '/authorize'
|
||||
| '/magicLinkSent'
|
||||
| '/token'
|
||||
| '/_sidebarLayout/profile'
|
||||
| '/_sidebarLayout/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
AuthorizeRoute: typeof AuthorizeRoute
|
||||
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
||||
TokenRoute: typeof TokenRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/token': {
|
||||
id: '/token'
|
||||
path: '/token'
|
||||
fullPath: '/token'
|
||||
preLoaderRoute: typeof TokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/magicLinkSent': {
|
||||
id: '/magicLinkSent'
|
||||
path: '/magicLinkSent'
|
||||
@@ -79,17 +107,17 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof MagicLinkSentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
'/authorize': {
|
||||
id: '/authorize'
|
||||
path: '/authorize'
|
||||
fullPath: '/authorize'
|
||||
preLoaderRoute: typeof AuthorizeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_sidebarLayout': {
|
||||
id: '/_sidebarLayout'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof SidebarLayoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
@@ -100,14 +128,23 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
||||
parentRoute: typeof SidebarLayoutRoute
|
||||
}
|
||||
'/_sidebarLayout/profile': {
|
||||
id: '/_sidebarLayout/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
|
||||
parentRoute: typeof SidebarLayoutRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarLayoutRouteChildren {
|
||||
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
|
||||
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
|
||||
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
||||
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
|
||||
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
||||
}
|
||||
|
||||
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
AuthorizeRoute: AuthorizeRoute,
|
||||
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||
TokenRoute: TokenRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { CheckinCard } from '@/components/workbenchCards/checkin';
|
||||
import { hasToken } from '@/lib/token';
|
||||
|
||||
export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
@@ -7,7 +6,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
loader: async () => {
|
||||
if (!hasToken()) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
to: '/authorize',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -21,7 +20,6 @@ function Index() {
|
||||
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
|
||||
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
|
||||
>
|
||||
<CheckinCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
14
client/src/routes/_sidebarLayout/profile.tsx
Normal file
14
client/src/routes/_sidebarLayout/profile.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { MainProfile } from '@/components/profile/main-profile';
|
||||
|
||||
export const Route = createFileRoute('/_sidebarLayout/profile')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6">
|
||||
<MainProfile />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
client/src/routes/authorize.tsx
Normal file
42
client/src/routes/authorize.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
import { generateOAuthState } from '@/lib/random';
|
||||
import { getToken } from '@/lib/token';
|
||||
|
||||
const authorizeSchema = z.object({
|
||||
response_type: z.literal('code').default('code'),
|
||||
client_id: z.literal('org_client').default('org_client'),
|
||||
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
|
||||
state: z.string().default(generateOAuthState()),
|
||||
});
|
||||
|
||||
export type AuthorizeSearchParams = z.infer<typeof authorizeSchema>;
|
||||
|
||||
export const Route = createFileRoute('/authorize')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(authorizeSchema),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const token = getToken();
|
||||
const oauthParams = Route.useSearch();
|
||||
if (token !== null) {
|
||||
const base = new URL(window.location.origin);
|
||||
const url = new URL('/api/v1/auth/redirect', base);
|
||||
url.searchParams.set('client_id', oauthParams.client_id);
|
||||
url.searchParams.set('response_type', oauthParams.response_type);
|
||||
url.searchParams.set('redirect_uri', oauthParams.redirect_uri);
|
||||
url.searchParams.set('state', oauthParams.state);
|
||||
window.location.href = url.toString();
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<LoginForm oauthParams={oauthParams} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
|
||||
import { setRefreshToken, setToken } from '@/lib/token';
|
||||
|
||||
const loginMagicLinkReceiverSchema = z.object({
|
||||
ticket: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(loginMagicLinkReceiverSchema),
|
||||
});
|
||||
|
||||
function ReceiveMagicLinkComponent() {
|
||||
const { ticket } = Route.useSearch();
|
||||
const { data } = useValidateMagicLink(ticket!);
|
||||
|
||||
setToken(data.data.access_token);
|
||||
setRefreshToken(data.data.refresh_token);
|
||||
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { ticket } = Route.useSearch();
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
{ticket === undefined ? <LoginForm /> : <ReceiveMagicLinkComponent />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
const paramsSchema = z.object({
|
||||
email: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/magicLinkSent')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(paramsSchema),
|
||||
@@ -16,7 +15,8 @@ function RouteComponent() {
|
||||
const { email } = Route.useSearch();
|
||||
return email !== undefined
|
||||
? (
|
||||
<div className="
|
||||
<div
|
||||
className="
|
||||
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
|
||||
>
|
||||
<NixOSLogo className="size-12" />
|
||||
@@ -29,5 +29,7 @@ function RouteComponent() {
|
||||
{email}
|
||||
</div>
|
||||
)
|
||||
: <Navigate to="/login" />;
|
||||
: (
|
||||
<Navigate to="/authorize" />
|
||||
);
|
||||
}
|
||||
|
||||
25
client/src/routes/token.tsx
Normal file
25
client/src/routes/token.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import z from 'zod';
|
||||
import { doSetTokenByCode } from '@/lib/token';
|
||||
|
||||
const tokenCodeSchema = z.object({
|
||||
code: z.string().nonempty(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/token')({
|
||||
component: RouteComponent,
|
||||
validateSearch: tokenCodeSchema,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { code } = Route.useSearch();
|
||||
const [status, setStatus] = useState('Loading...');
|
||||
const navigate = useNavigate();
|
||||
doSetTokenByCode(code).then(() => {
|
||||
void navigate({ to: '/' });
|
||||
}).catch((_) => {
|
||||
setStatus('Error getting token');
|
||||
});
|
||||
return <div>{status}</div>;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://10.0.0.10:8000',
|
||||
'/api': 'http://10.0.0.250:8000',
|
||||
},
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user