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/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@marsidev/react-turnstile": "^1.4.0",
|
"@marsidev/react-turnstile": "^1.4.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -29,12 +30,16 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-form": "^1.27.7",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-router": "^1.141.6",
|
"@tanstack/react-router": "^1.141.6",
|
||||||
"@tanstack/react-router-devtools": "^1.141.6",
|
"@tanstack/react-router-devtools": "^1.141.6",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/zod-adapter": "^1.143.4",
|
"@tanstack/zod-adapter": "^1.143.4",
|
||||||
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"base-64": "^1.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"culori": "^4.0.2",
|
"culori": "^4.0.2",
|
||||||
@@ -44,10 +49,13 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.69.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"utf8": "^3.0.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.2.1",
|
"zod": "^4.2.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -56,13 +64,16 @@
|
|||||||
"@antfu/eslint-config": "^6.7.1",
|
"@antfu/eslint-config": "^6.7.1",
|
||||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/base-64": "^1.0.2",
|
||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/utf8": "^3.0.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.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
@@ -1,4 +1,5 @@
|
|||||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
|
oauthParams,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'> & {
|
||||||
|
oauthParams: AuthorizeSearchParams;
|
||||||
|
}) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
@@ -28,7 +32,7 @@ export function LoginForm({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(formRef.current!);
|
const formData = new FormData(formRef.current!);
|
||||||
const email = formData.get('email')! as string;
|
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 } });
|
void navigate({ to: '/magicLinkSent', search: { email } });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(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 * as React from 'react';
|
||||||
|
|
||||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/sidebar/nav-main';
|
||||||
import { NavSecondary } from '@/components/nav-secondary';
|
import { NavSecondary } from '@/components/sidebar/nav-secondary';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -16,30 +11,9 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
import { NavUser } from './nav-user';
|
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>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="offcanvas" {...props}>
|
<Sidebar collapsible="offcanvas" {...props}>
|
||||||
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={navData.navMain} />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={navData.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import * as React from 'react';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -27,12 +28,16 @@ export function NavSecondary({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild>
|
<Link to={item.url}>
|
||||||
<a href={item.url}>
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
import { useLogout } from '@/hooks/useLogout';
|
import { useLogout } from '@/hooks/useLogout';
|
||||||
import { withFallback } from './hoc/with-fallback';
|
import { withFallback } from '../hoc/with-fallback';
|
||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
|
||||||
function NavUser_() {
|
function NavUser_() {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
|
|
||||||
export function SiteHeader() {
|
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 (
|
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)">
|
<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">
|
<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"
|
orientation="vertical"
|
||||||
className="mx-2 data-[orientation=vertical]:h-4"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-base font-medium">工作台</h1>
|
<h1 className="text-base font-medium">{currentTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function FieldError({
|
|||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
errors?: Array<{ message?: string } | undefined>;
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(async () => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return 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 { useMutation } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
|
||||||
interface GetMagicLinkPayload {
|
interface GetMagicLinkPayload extends AuthorizeSearchParams {
|
||||||
email: string;
|
email: string;
|
||||||
turnstile_token: string;
|
turnstile_token: string;
|
||||||
}
|
}
|
||||||
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
|
|||||||
export function useGetMagicLink() {
|
export function useGetMagicLink() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: GetMagicLinkPayload) => {
|
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;
|
nickname: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
checkin: string | null;
|
bio: string;
|
||||||
}
|
}
|
||||||
>('/user/info');
|
>('/user/info');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useLogout() {
|
|||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
void navigate({ to: '/login' });
|
void navigate({ to: '/authorize' });
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return { logout };
|
return { logout };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => {
|
|||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e instanceof AxiosError && e.status === 401) {
|
if (e instanceof AxiosError && e.status === 401) {
|
||||||
await router.navigate({ to: '/login' });
|
await router.navigate({ to: '/authorize' });
|
||||||
return Promise.reject(error);
|
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('');
|
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() {
|
export async function doRefreshToken() {
|
||||||
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
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';
|
import type { ClassValue } from 'clsx';
|
||||||
|
// eslint-disable-next-line unicorn/prefer-node-protocol
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as TokenRouteImport } from './routes/token'
|
||||||
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
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 SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
||||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
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({
|
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
||||||
id: '/magicLinkSent',
|
id: '/magicLinkSent',
|
||||||
path: '/magicLinkSent',
|
path: '/magicLinkSent',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const AuthorizeRoute = AuthorizeRouteImport.update({
|
||||||
id: '/login',
|
id: '/authorize',
|
||||||
path: '/login',
|
path: '/authorize',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
||||||
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => SidebarLayoutRoute,
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||||
|
id: '/profile',
|
||||||
|
path: '/profile',
|
||||||
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/login': typeof LoginRoute
|
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
|
'/authorize': typeof AuthorizeRoute
|
||||||
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/login' | '/magicLinkSent' | '/'
|
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/login' | '/magicLinkSent' | '/'
|
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_sidebarLayout'
|
| '/_sidebarLayout'
|
||||||
| '/login'
|
| '/authorize'
|
||||||
| '/magicLinkSent'
|
| '/magicLinkSent'
|
||||||
|
| '/token'
|
||||||
|
| '/_sidebarLayout/profile'
|
||||||
| '/_sidebarLayout/'
|
| '/_sidebarLayout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
AuthorizeRoute: typeof AuthorizeRoute
|
||||||
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
||||||
|
TokenRoute: typeof TokenRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/token': {
|
||||||
|
id: '/token'
|
||||||
|
path: '/token'
|
||||||
|
fullPath: '/token'
|
||||||
|
preLoaderRoute: typeof TokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/magicLinkSent': {
|
'/magicLinkSent': {
|
||||||
id: '/magicLinkSent'
|
id: '/magicLinkSent'
|
||||||
path: '/magicLinkSent'
|
path: '/magicLinkSent'
|
||||||
@@ -79,17 +107,17 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof MagicLinkSentRouteImport
|
preLoaderRoute: typeof MagicLinkSentRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/authorize': {
|
||||||
id: '/login'
|
id: '/authorize'
|
||||||
path: '/login'
|
path: '/authorize'
|
||||||
fullPath: '/login'
|
fullPath: '/authorize'
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof AuthorizeRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_sidebarLayout': {
|
'/_sidebarLayout': {
|
||||||
id: '/_sidebarLayout'
|
id: '/_sidebarLayout'
|
||||||
path: ''
|
path: ''
|
||||||
fullPath: ''
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SidebarLayoutRouteImport
|
preLoaderRoute: typeof SidebarLayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
@@ -100,14 +128,23 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
||||||
parentRoute: typeof SidebarLayoutRoute
|
parentRoute: typeof SidebarLayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_sidebarLayout/profile': {
|
||||||
|
id: '/_sidebarLayout/profile'
|
||||||
|
path: '/profile'
|
||||||
|
fullPath: '/profile'
|
||||||
|
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
|
||||||
|
parentRoute: typeof SidebarLayoutRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLayoutRouteChildren {
|
interface SidebarLayoutRouteChildren {
|
||||||
|
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
|
||||||
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
||||||
|
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
|
||||||
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
AuthorizeRoute: AuthorizeRoute,
|
||||||
MagicLinkSentRoute: MagicLinkSentRoute,
|
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||||
|
TokenRoute: TokenRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
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 { SiteHeader } from '@/components/site-header';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
import { CheckinCard } from '@/components/workbenchCards/checkin';
|
|
||||||
import { hasToken } from '@/lib/token';
|
import { hasToken } from '@/lib/token';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_sidebarLayout/')({
|
export const Route = createFileRoute('/_sidebarLayout/')({
|
||||||
@@ -7,7 +6,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
|||||||
loader: async () => {
|
loader: async () => {
|
||||||
if (!hasToken()) {
|
if (!hasToken()) {
|
||||||
throw redirect({
|
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
|
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"
|
*: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>
|
||||||
</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({
|
const paramsSchema = z.object({
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute('/magicLinkSent')({
|
export const Route = createFileRoute('/magicLinkSent')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(paramsSchema),
|
validateSearch: zodValidator(paramsSchema),
|
||||||
@@ -16,7 +15,8 @@ function RouteComponent() {
|
|||||||
const { email } = Route.useSearch();
|
const { email } = Route.useSearch();
|
||||||
return email !== undefined
|
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"
|
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
|
||||||
>
|
>
|
||||||
<NixOSLogo className="size-12" />
|
<NixOSLogo className="size-12" />
|
||||||
@@ -29,5 +29,7 @@ function RouteComponent() {
|
|||||||
{email}
|
{email}
|
||||||
</div>
|
</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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://10.0.0.10:8000',
|
'/api': 'http://10.0.0.250:8000',
|
||||||
},
|
},
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user