Compare commits
12 Commits
bd8eecbc7d
...
noa.virell
| Author | SHA1 | Date | |
|---|---|---|---|
|
af43b86a61
|
|||
|
0a4f459188
|
|||
|
61d2d2aef3
|
|||
|
0b710fd538
|
|||
|
d70ade4907
|
|||
|
a98ab26fa4
|
|||
|
62da1e096e
|
|||
|
fd1c89392f
|
|||
|
ae93f49691
|
|||
|
743f8373b0
|
|||
|
4796653896
|
|||
|
4dfd4cd529
|
@@ -9,6 +9,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",
|
||||
@@ -24,11 +25,13 @@
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -39,6 +42,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -254,6 +258,8 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
@@ -406,6 +412,8 @@
|
||||
|
||||
"@sindresorhus/base62": ["@sindresorhus/base62@1.0.0", "", {}, "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="],
|
||||
|
||||
"@svgr/babel-plugin-add-jsx-attribute": ["@svgr/babel-plugin-add-jsx-attribute@8.0.0", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g=="],
|
||||
@@ -466,12 +474,20 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
|
||||
|
||||
"@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="],
|
||||
|
||||
"@tanstack/form-core": ["@tanstack/form-core@1.27.7", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="],
|
||||
|
||||
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
|
||||
|
||||
"@tanstack/react-form": ["@tanstack/react-form@1.27.7", "", { "dependencies": { "@tanstack/form-core": "1.27.7", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.141.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.141.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg=="],
|
||||
@@ -500,6 +516,8 @@
|
||||
|
||||
"@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.143.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-yrdxNCKPaMjIXM5ZFf3jWNtGlOEZWh2nPdN5NQagkOrYK/l87SZRASB/vFerBXupXPaXvEL8C0qzb894trHW5w=="],
|
||||
|
||||
"@tanstack/zod-form-adapter": ["@tanstack/zod-form-adapter@0.42.1", "", { "dependencies": { "@tanstack/form-core": "0.42.1" }, "peerDependencies": { "zod": "^3.x" } }, "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -1238,6 +1256,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
@@ -1540,10 +1560,14 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tanstack/zod-form-adapter/@tanstack/form-core": ["@tanstack/form-core@0.42.1", "", { "dependencies": { "@tanstack/store": "^0.7.0" } }, "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -1634,6 +1658,8 @@
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tanstack/zod-form-adapter/@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -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,11 +30,13 @@
|
||||
"@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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -44,6 +47,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -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);
|
||||
|
||||
104
client/src/components/profile/form.tsx
Normal file
104
client/src/components/profile/form.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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 SettingsForm() {
|
||||
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();
|
||||
}}
|
||||
className="space-y-3 max-w-5xl mr-auto py-10"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
34
client/src/components/profile/main-profile.tsx
Normal file
34
client/src/components/profile/main-profile.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
|
||||
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>
|
||||
<Button className="w-full mt-4" variant="outline" size="lg">
|
||||
编辑个人资料
|
||||
</Button>
|
||||
<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">
|
||||
{/* Bio */}
|
||||
</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}>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</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,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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ export function useUserInfo() {
|
||||
>('/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 };
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/token': typeof TokenRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
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,11 +107,11 @@ 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': {
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
loader: async () => {
|
||||
if (!hasToken()) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
to: '/authorize',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -20,13 +20,19 @@ search:
|
||||
host: 127.0.0.1
|
||||
api_key: ""
|
||||
email:
|
||||
resend_api_key: abc
|
||||
host:
|
||||
port:
|
||||
username:
|
||||
password:
|
||||
security:
|
||||
insecure_skip_verify:
|
||||
from:
|
||||
secrets:
|
||||
jwt_secret: example
|
||||
turnstile_secret: example
|
||||
client_secret_key: example
|
||||
ttl:
|
||||
magic_link_ttl: 10m
|
||||
jwt_ttl: 15s
|
||||
auth_code_ttl: 10m
|
||||
access_ttl: 15s
|
||||
refresh_ttl: 168h
|
||||
checkin_code_ttl: 10m
|
||||
|
||||
@@ -40,17 +40,23 @@ type search struct {
|
||||
}
|
||||
|
||||
type email struct {
|
||||
ResendApiKey string `yaml:"resend_api_key"`
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Security string `yaml:"security"`
|
||||
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
||||
From string `yaml:"from"`
|
||||
}
|
||||
|
||||
type secrets struct {
|
||||
JwtSecret string `yaml:"jwt_secret"`
|
||||
TurnstileSecret string `yaml:"turnstile_secret"`
|
||||
ClientSecretKey string `yaml:"client_secret_key"`
|
||||
}
|
||||
|
||||
type ttl struct {
|
||||
MagicLinkTTL string `yaml:"magic_link_ttl"`
|
||||
AuthCodeTTL string `yaml:"auth_code_ttl"`
|
||||
AccessTTL string `yaml:"access_ttl"`
|
||||
RefreshTTL string `yaml:"refresh_ttl"`
|
||||
CheckinCodeTTL string `yaml:"checkin_code_ttl"`
|
||||
|
||||
@@ -1 +1,91 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
|
||||
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
|
||||
}
|
||||
|
||||
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
|
||||
var client Client
|
||||
if err := Database.
|
||||
Where("client_id = ?", clientId).
|
||||
First(&client).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (self *Client) GetDecryptedSecret() (string, error) {
|
||||
secretKey := viper.GetString("secrets.client_secret_key")
|
||||
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
|
||||
return string(secret), err
|
||||
}
|
||||
|
||||
type ClientParams struct {
|
||||
ClientId string
|
||||
ClientName string
|
||||
RedirectUri []string
|
||||
}
|
||||
|
||||
func (self *Client) Create(params *ClientParams) (*Client, error) {
|
||||
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encKey := viper.GetString("secrets.client_secret_key")
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientSecret := base64.RawURLEncoding.EncodeToString(b)
|
||||
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
UUID: uuid.New(),
|
||||
ClientId: params.ClientId,
|
||||
ClientSecret: encryptedSecret,
|
||||
ClientName: params.ClientName,
|
||||
RedirectUri: jsonRedirectUri,
|
||||
}
|
||||
|
||||
if err := Database.Create(&client).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (self *Client) ValidateRedirectURI(redirectURI string) error {
|
||||
var uris []string
|
||||
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, prefix := range uris {
|
||||
if strings.HasPrefix(redirectURI, prefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("redirect uri not match")
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{})
|
||||
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
|
||||
if err != nil {
|
||||
log.Error("[Database] Error migrating database: ", err)
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -64,6 +64,8 @@ require (
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
|
||||
gorm.io/datatypes v1.2.7 // indirect
|
||||
gorm.io/driver/mysql v1.5.6 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -139,7 +139,11 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||
|
||||
5
justfile
5
justfile
@@ -15,9 +15,8 @@ install:
|
||||
cd {{ client_dir }} && {{ bun_cmd }} install
|
||||
|
||||
clean:
|
||||
rm -rf {{ output_dir }}
|
||||
mkdir -p {{ output_dir }}
|
||||
cp {{ join(project_dir, "config.default.yaml") }} {{ join(output_dir, "config.yaml") }}
|
||||
mkdir -p .outputs
|
||||
find .outputs -mindepth 1 ! -path .outputs/config.yaml -exec rm -rf {} +
|
||||
|
||||
client:
|
||||
cd {{ client_dir }} && {{ bun_cmd }} dev
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func JWTAuth() gin.HandlerFunc {
|
||||
func JWTAuth(required bool) gin.HandlerFunc {
|
||||
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
|
||||
token := new(cryptography.Token)
|
||||
uid, err := token.HeaderVerify(auth)
|
||||
authtoken := new(authtoken.Token)
|
||||
uid, err := authtoken.HeaderVerify(auth)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"status": err.Error()})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if uid == "" {
|
||||
c.Set("user_id", "")
|
||||
c.Next()
|
||||
if required == true && uid == "" {
|
||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -25,29 +25,29 @@ func NewAuthCode(email string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(b)
|
||||
code := base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
store.Store(token, Token{
|
||||
store.Store(code, Token{
|
||||
Email: email,
|
||||
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
|
||||
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.auth_code_ttl")),
|
||||
})
|
||||
|
||||
return token, nil
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// Verify magic token
|
||||
func VerifyAuthCode(token string) (string, bool) {
|
||||
val, ok := store.Load(token)
|
||||
func VerifyAuthCode(code string) (string, bool) {
|
||||
val, ok := store.Load(code)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
t := val.(Token)
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
store.Delete(token)
|
||||
store.Delete(code)
|
||||
return "", false
|
||||
}
|
||||
|
||||
store.Delete(token)
|
||||
store.Delete(code)
|
||||
return t.Email, true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cryptography
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
70
pkgs/email/email.go
Normal file
70
pkgs/email/email.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
gomail "gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
dialer *gomail.Dialer
|
||||
from string
|
||||
}
|
||||
|
||||
func NewSMTPClient() (*Client, error) {
|
||||
host := viper.GetString("email.host")
|
||||
port := viper.GetInt("email.port")
|
||||
user := viper.GetString("email.username")
|
||||
pass := viper.GetString("email.password")
|
||||
from := viper.GetString("email.from")
|
||||
|
||||
security := strings.ToLower(viper.GetString("email.security"))
|
||||
insecure := viper.GetBool("email.insecure_skip_verify")
|
||||
|
||||
if host == "" || port == 0 || user == "" || pass == "" {
|
||||
return nil, errors.New("SMTP config not set")
|
||||
}
|
||||
|
||||
dialer := gomail.NewDialer(host, port, user, pass)
|
||||
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: insecure,
|
||||
}
|
||||
|
||||
switch security {
|
||||
case "ssl":
|
||||
dialer.SSL = true
|
||||
case "starttls":
|
||||
dialer.SSL = false
|
||||
case "plain", "":
|
||||
dialer.SSL = false
|
||||
dialer.TLSConfig = nil
|
||||
default:
|
||||
return nil, errors.New("unknown smtp security mode: " + security)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
dialer: dialer,
|
||||
from: from,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Send(to, subject, html string) (string, error) {
|
||||
m := gomail.NewMessage()
|
||||
|
||||
m.SetHeader("From", c.from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/html", html)
|
||||
|
||||
if err := c.dialer.DialAndSend(m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return time.Now().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Resend service client
|
||||
func NewResendClient() (*Client, error) {
|
||||
key := viper.GetString("email.resend_api_key")
|
||||
if key == "" {
|
||||
return nil, errors.New("RESEND_API_KEY not set")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
apiKey: key,
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type sendEmailRequest struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type sendEmailResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Send email by resend API
|
||||
func (c *Client) Send(to, subject, html string) (string, error) {
|
||||
reqBody := sendEmailRequest{
|
||||
From: viper.GetString("email.from"),
|
||||
To: []string{to},
|
||||
Subject: subject,
|
||||
HTML: html,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://api.resend.com/emails",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", errors.New("resend send failed")
|
||||
}
|
||||
|
||||
var res sendEmailResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return res.ID, nil
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"nixcn-cms/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.GET("/redirect", Redirect)
|
||||
r.GET("/redirect", Redirect, middleware.JWTAuth(false))
|
||||
r.POST("/magic", Magic)
|
||||
r.POST("/refresh", Refresh)
|
||||
r.POST("/token", Token)
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"net/url"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
"nixcn-cms/pkgs/email"
|
||||
"nixcn-cms/pkgs/turnstile"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -43,83 +38,37 @@ func Magic(c *gin.Context) {
|
||||
c.JSON(500, gin.H{"status": "code gen failed"})
|
||||
}
|
||||
|
||||
uri := viper.GetString("server.external_url") +
|
||||
"/api/v1/auth/redirect?" +
|
||||
"code=" + code +
|
||||
"&redirect_uri=" + req.RedirectUri +
|
||||
"&state=" + req.State
|
||||
externalUrl := viper.GetString("server.external_url")
|
||||
url, err := url.Parse(externalUrl)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "invalid external url"})
|
||||
}
|
||||
|
||||
debugMode := viper.GetString("server.debug_mode")
|
||||
if debugMode == "true" {
|
||||
log.Info("Magic link for " + req.Email + " : " + uri)
|
||||
url.Path = "/api/v1/auth/redirect"
|
||||
query := url.Query()
|
||||
query.Set("code", code)
|
||||
query.Set("redirect_uri", req.RedirectUri)
|
||||
query.Set("state", req.State)
|
||||
query.Set("client_id", req.ClientId)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
debugMode := viper.GetBool("server.debug_mode")
|
||||
if debugMode {
|
||||
c.JSON(200, gin.H{"status": "magiclink sent", "uri": url.String()})
|
||||
return
|
||||
} else {
|
||||
// Send email using resend
|
||||
resend, err := email.NewResendClient()
|
||||
emailClient, err := email.NewSMTPClient()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
c.JSON(500, gin.H{"status": "invalid email config"})
|
||||
return
|
||||
}
|
||||
resend.Send(
|
||||
emailClient.Send(
|
||||
req.Email,
|
||||
"NixCN CMS Email Verify",
|
||||
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+uri+">"+uri+"</a>",
|
||||
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "magic link sent"})
|
||||
}
|
||||
|
||||
func VerifyMagicLink(c *gin.Context) {
|
||||
// Get token from url
|
||||
magicToken := c.Query("token")
|
||||
if magicToken == "" {
|
||||
c.JSON(400, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify email token
|
||||
email, ok := authcode.VerifyAuthCode(magicToken)
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if user exists
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByEmail(email)
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create user
|
||||
user.UUID = uuid.New()
|
||||
user.UserId = uuid.New()
|
||||
user.Email = email
|
||||
user.PermissionLevel = 10
|
||||
if err := user.Create(); err != nil {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate jwt
|
||||
JwtTool := cryptography.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error generating tokens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,128 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"net/url"
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Redirect(c *gin.Context) {
|
||||
|
||||
clientId := c.Query("client_id")
|
||||
if clientId == "" {
|
||||
c.JSON(400, gin.H{"status": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
redirectUri := c.Query("redirect_uri")
|
||||
if redirectUri == "" {
|
||||
c.JSON(400, gin.H{"status": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
c.JSON(400, gin.H{"status": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok || userIdOrig == "" {
|
||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||
return
|
||||
}
|
||||
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByUserId(userId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "failed to get user id"})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := authcode.NewAuthCode(user.Email)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "code gen failed"})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := url.Parse(redirectUri)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"status": "invalid redirect uri"})
|
||||
return
|
||||
}
|
||||
query := url.Query()
|
||||
query.Set("code", code)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(302, url.String())
|
||||
}
|
||||
|
||||
// Verify email token
|
||||
email, ok := authcode.VerifyAuthCode(code)
|
||||
if !ok {
|
||||
c.JSON(403, gin.H{"status": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if user exists
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByEmail(email)
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create user
|
||||
user.UUID = uuid.New()
|
||||
user.UserId = uuid.New()
|
||||
user.Email = email
|
||||
user.PermissionLevel = 10
|
||||
if err := user.Create(); err != nil {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clientData := new(data.Client)
|
||||
client, err := clientData.GetClientByClientId(clientId)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"status": "client not found"})
|
||||
return
|
||||
}
|
||||
|
||||
err = client.ValidateRedirectURI(redirectUri)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"status": "redirect uri not match"})
|
||||
return
|
||||
}
|
||||
|
||||
newCode, err := authcode.NewAuthCode(email)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := url.Parse(redirectUri)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"status": "invalid redirect uri"})
|
||||
return
|
||||
}
|
||||
query := url.Query()
|
||||
query.Set("code", newCode)
|
||||
url.RawQuery = query.Encode()
|
||||
|
||||
c.Redirect(302, url.String())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nixcn-cms/internal/cryptography"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
JwtTool := cryptography.Token{
|
||||
JwtTool := authtoken.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func Refresh(c *gin.Context) {
|
||||
refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"statis": "error renew refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"nixcn-cms/data"
|
||||
"nixcn-cms/pkgs/authcode"
|
||||
"nixcn-cms/pkgs/authtoken"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Token(c *gin.Context) {
|
||||
var req TokenRequest
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"status": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
email, ok := authcode.VerifyAuthCode(req.Code)
|
||||
if !ok {
|
||||
c.JSON(403, gin.H{"status": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
userData := new(data.User)
|
||||
user, err := userData.GetByEmail(email)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate jwt
|
||||
JwtTool := authtoken.Token{
|
||||
Application: viper.GetString("server.application"),
|
||||
}
|
||||
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error generating tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ import (
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.Use(middleware.JWTAuth())
|
||||
r.Use(middleware.JWTAuth(true))
|
||||
r.GET("/info", Info)
|
||||
}
|
||||
|
||||
@@ -11,26 +11,20 @@ func Info(c *gin.Context) {
|
||||
eventData := new(data.Event)
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "undefinded event id",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "undefinded event id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse event id
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error parsing string to uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
|
||||
return
|
||||
}
|
||||
|
||||
event, err := eventData.GetEventById(eventId)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "event id not found",
|
||||
})
|
||||
c.JSON(404, gin.H{"status": "event id not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,69 +11,51 @@ func Checkin(c *gin.Context) {
|
||||
data := new(data.Attendance)
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "unauthorized",
|
||||
})
|
||||
c.JSON(403, gin.H{"status": "userid error"})
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed to parse uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||
}
|
||||
|
||||
// Get event id from query
|
||||
eventIdOrig, ok := c.GetQuery("event_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "undefinded event id",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "undefinded event id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse event id to uuid
|
||||
eventId, err := uuid.Parse(eventIdOrig)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error parsing string to uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "error parsing string to uuid"})
|
||||
return
|
||||
}
|
||||
data.UserId = userId
|
||||
code, err := data.GenCheckinCode(eventId)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error generating code",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "error generating code"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"checkin_code": code,
|
||||
})
|
||||
c.JSON(200, gin.H{"checkin_code": code})
|
||||
}
|
||||
|
||||
func CheckinSubmit(c *gin.Context) {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "unauthorized",
|
||||
})
|
||||
if userIdOrig.(string) == "" || !ok {
|
||||
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed to parse uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||
}
|
||||
|
||||
userData := new(data.User)
|
||||
userData.GetByUserId(userId)
|
||||
if userData.PermissionLevel <= 20 {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "access denied",
|
||||
})
|
||||
c.JSON(403, gin.H{"status": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,13 +67,9 @@ func CheckinSubmit(c *gin.Context) {
|
||||
attendanceData := new(data.Attendance)
|
||||
err = attendanceData.VerifyCheckinCode(req.ChekinCode)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error verify checkin code",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "error verify checkin code"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
})
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Handler(r *gin.RouterGroup) {
|
||||
r.Use(middleware.JWTAuth())
|
||||
r.Use(middleware.JWTAuth(true))
|
||||
r.GET("/info", Info)
|
||||
r.GET("/checkin", Checkin)
|
||||
r.POST("/checkin/submit", CheckinSubmit)
|
||||
|
||||
@@ -11,24 +11,19 @@ func Info(c *gin.Context) {
|
||||
userData := new(data.User)
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "user not found",
|
||||
})
|
||||
c.JSON(403, gin.H{"status": "userid error"})
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed to parse uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := userData.GetByUserId(userId)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{
|
||||
"status": "user not found",
|
||||
})
|
||||
c.JSON(404, gin.H{"status": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -17,34 +17,26 @@ func List(c *gin.Context) {
|
||||
}
|
||||
offset, ok := c.GetQuery("offset")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "offset not found",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "offset not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse string to int64
|
||||
limitNum, err := strconv.ParseInt(limit, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "parse string to int error",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "parse string to int error"})
|
||||
return
|
||||
}
|
||||
offsetNum, err := strconv.ParseInt(offset, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "parse string to int error",
|
||||
})
|
||||
c.JSON(400, gin.H{"status": "parse string to int error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user list from search engine
|
||||
list, err := data.FastListUsers(limitNum, offsetNum)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed list users from meilisearch",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "failed list users from meilisearch"})
|
||||
}
|
||||
c.JSON(200, list)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func Query(c *gin.Context) {
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"status": "could not found user_id"})
|
||||
c.JSON(403, gin.H{"status": "userid error"})
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
@@ -44,7 +44,5 @@ func Query(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"checkin_at": attendance.CheckinAt,
|
||||
})
|
||||
c.JSON(200, gin.H{"checkin_at": attendance.CheckinAt})
|
||||
}
|
||||
|
||||
@@ -8,33 +8,27 @@ import (
|
||||
)
|
||||
|
||||
func Update(c *gin.Context) {
|
||||
var ReqInfo data.User
|
||||
c.BindJSON(&ReqInfo)
|
||||
|
||||
// New user model
|
||||
user := new(data.User)
|
||||
userIdOrig, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "can not found user id",
|
||||
})
|
||||
c.JSON(403, gin.H{"status": "userid error"})
|
||||
return
|
||||
}
|
||||
userId, err := uuid.Parse(userIdOrig.(string))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "failed to parse uuid",
|
||||
})
|
||||
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||
}
|
||||
|
||||
var ReqInfo data.User
|
||||
c.BindJSON(&ReqInfo)
|
||||
|
||||
// Get user info
|
||||
user.GetByUserId(userId)
|
||||
|
||||
// Reject permission 0 user
|
||||
if user.PermissionLevel == 0 {
|
||||
c.JSON(403, gin.H{
|
||||
"status": "premission denied",
|
||||
})
|
||||
c.JSON(403, gin.H{"status": "premission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +40,5 @@ func Update(c *gin.Context) {
|
||||
// Update user info
|
||||
user.UpdateByUserID(userId)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
})
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user