feat(client): magic link sign-in

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2025-12-25 01:38:43 +08:00
committed by Asai Neko
parent e4e15b2f6e
commit 606c74c587
12 changed files with 167 additions and 631 deletions

View File

@@ -9,6 +9,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",
"@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",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -27,6 +28,7 @@
"@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",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -264,6 +266,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.0", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-3aR7mh4lATeayWt6GjWuYyLjM0GL148z7/ZQl0rLKGpDYIrWgoU2PYsdAdA9fzH+JysW3Q2OaPfHvv66cwcAZg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -488,6 +492,8 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="],
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.143.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-yrdxNCKPaMjIXM5ZFf3jWNtGlOEZWh2nPdN5NQagkOrYK/l87SZRASB/vFerBXupXPaXvEL8C0qzb894trHW5w=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],

View File

@@ -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",
"@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",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -32,6 +33,7 @@
"@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",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View File

@@ -1,3 +1,8 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import NixOSLogo from '@/assets/nixos.svg?react'; import NixOSLogo from '@/assets/nixos.svg?react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -6,42 +11,70 @@ import {
FieldLabel, FieldLabel,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function LoginForm({ export function LoginForm({
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<'div'>) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
const { mutateAsync, isPending } = useGetMagicLink();
const navigate = useNavigate();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);
toast.error('请求登录链接失败');
turnstileRef.current?.reset();
});
};
return ( return (
<div className={cn('flex flex-col gap-6', className)} {...props}> <div className={cn('flex flex-col gap-6', className)} {...props}>
<form> <form ref={formRef} onSubmit={handleSubmit}>
<FieldGroup> <FieldGroup>
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<a <div className="flex size-8 items-center justify-center rounded-md">
href="#" <NixOSLogo className="size-6" />
className="flex flex-col items-center gap-2 font-medium" </div>
> <span className="sr-only">Nix CN Meetup #2</span>
<div className="flex size-8 items-center justify-center rounded-md">
<NixOSLogo className="size-6" />
</div>
<span className="sr-only">Nix CN Meetup #2</span>
</a>
<h1 className="text-xl font-bold"> Nix CN Meetup #2</h1> <h1 className="text-xl font-bold"> Nix CN Meetup #2</h1>
</div> </div>
<Field> <Field>
<FieldLabel htmlFor="email">Email</FieldLabel> <FieldLabel htmlFor="email">Email</FieldLabel>
<Input <Input
id="email" id="email"
name="email"
type="email" type="email"
placeholder="edolstra@gmail.com" placeholder="edolstra@gmail.com"
required required
/> />
</Field> </Field>
<Field> <Field>
<Button type="submit"></Button> <Button type="submit" disabled={token === null || isPending}>
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
</Button>
</Field> </Field>
</FieldGroup> </FieldGroup>
</form> </form>
<Turnstile
ref={turnstileRef}
siteKey="0x4AAAAAACI5pu-lNWFc6Wu1"
options={{
refreshExpired: 'auto',
}}
onSuccess={(token) => {
setToken(token);
}}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload {
email: string;
turnstile_token: string;
}
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<object>('/auth/magic', payload);
},
});
}

View File

@@ -0,0 +1,11 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useValidateMagicLink(ticket: string) {
return useSuspenseQuery({
queryKey: ['validateMagicLink', ticket],
queryFn: async () => {
return axiosClient.get<{ jwt_token: string; email: string }>('/auth/magic/verify', { params: { token: ticket } });
},
});
}

View File

@@ -4,7 +4,7 @@ import { router } from '@/lib/router';
import { getToken, hasToken } from './token'; import { getToken, hasToken } from './token';
export const axiosClient = axios.create({ export const axiosClient = axios.create({
baseURL: '/api/', baseURL: '/api/v1/',
}); });
axiosClient.interceptors.request.use((config) => { axiosClient.interceptors.request.use((config) => {

View File

@@ -9,10 +9,16 @@
// 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 MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
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'
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent',
path: '/magicLinkSent',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@@ -30,33 +36,49 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/': typeof SidebarLayoutIndexRoute '/': typeof SidebarLayoutIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/': 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 '/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute '/_sidebarLayout/': typeof SidebarLayoutIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/' fullPaths: '/login' | '/magicLinkSent' | '/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/login' | '/' to: '/login' | '/magicLinkSent' | '/'
id: '__root__' | '/_sidebarLayout' | '/login' | '/_sidebarLayout/' id:
| '__root__'
| '/_sidebarLayout'
| '/login'
| '/magicLinkSent'
| '/_sidebarLayout/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/magicLinkSent': {
id: '/magicLinkSent'
path: '/magicLinkSent'
fullPath: '/magicLinkSent'
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/login': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -96,6 +118,7 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren, SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRootRoute, Outlet } from '@tanstack/react-router'; import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { ThemeProvider } from '@/components/theme-provider'; import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import '@/index.css'; import '@/index.css';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -15,6 +16,7 @@ function RootLayout() {
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
<TanStackRouterDevtools /> <TanStackRouterDevtools />
<Toaster position="top-right" />
</> </>
); );
} }

View File

@@ -1,15 +1,34 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form'; import { LoginForm } from '@/components/login-form';
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
import { setToken } from '@/lib/token';
const loginMagicLinkReceiverSchema = z.object({
ticket: z.string().optional(),
});
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
component: RouteComponent, component: RouteComponent,
validateSearch: zodValidator(loginMagicLinkReceiverSchema),
}); });
function ReceiveMagicLinkComponent() {
const { ticket } = Route.useSearch();
const { data } = useValidateMagicLink(ticket!);
setToken(data.data.jwt_token);
return <Navigate to="/" />;
}
function RouteComponent() { function RouteComponent() {
const { ticket } = Route.useSearch();
return ( return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <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"> <div className="w-full max-w-sm">
<LoginForm /> {ticket === undefined ? <LoginForm /> : <ReceiveMagicLinkComponent />}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,33 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
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),
});
function RouteComponent() {
const { email } = Route.useSearch();
return email !== undefined
? (
<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" />
<span
aria-hidden="true"
className="mx-2 inline-block h-6 w-px bg-current opacity-40"
/>
{' '}
{email}
</div>
)
: <Navigate to="/login" />;
}

View File

@@ -21,4 +21,10 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: {
proxy: {
'/api': 'http://10.0.0.10:8000',
},
allowedHosts: ['dev.sne.moe'],
},
}); });