Compare commits

...

35 Commits

Author SHA1 Message Date
af43b86a61 feat(client): refactor auth/login
Some checks failed
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build failed
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-02 16:48:16 +08:00
0a4f459188 feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-02 16:47:37 +08:00
61d2d2aef3 Sign new code for new redirect
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:47:15 +08:00
0b710fd538 Change magic_link_ttl old name to auth_code_ttl
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:37:30 +08:00
d70ade4907 Change resend to using smtp
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:26:21 +08:00
a98ab26fa4 Add oauth2 like auth service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 15:57:42 +08:00
62da1e096e Fix default config
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:59:43 +08:00
fd1c89392f Add abort for jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:55:47 +08:00
ae93f49691 Fix jwt middleware cnext
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:52:04 +08:00
743f8373b0 Fix request return
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:34:13 +08:00
4796653896 Fix jwt middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:17:25 +08:00
4dfd4cd529 Modify auth middleware
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:00:02 +08:00
bd8eecbc7d Fix dup err logic
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:37:27 +08:00
cbec9bf2b3 Modify jwt middleware logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:36:07 +08:00
3d685b5a86 Add hot reload for backend
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 20:22:55 +08:00
83fe326962 Add event type for event table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:59:57 +08:00
5b6bc9ce42 Return user bio in user info service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:54:43 +08:00
e0e1abab93 Add Bio to user table, set varchar for role in attendance table
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:52:24 +08:00
9f927c907a Fix a bug
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:27:00 +08:00
27ba3b7bef Add aes cryptography library
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:25:44 +08:00
63f71d3b81 Add bcrypt and aes crypto lib
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:24:41 +08:00
e40d175c8e Remove user.type from auth/magic service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:12:05 +08:00
304e1d95ed Refactor checkin table to attendance table
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:08:59 +08:00
acd3c95c80 Refactor mass data structure
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 13:31:28 +08:00
8973d518a2 refactor(client): qr dialog skeleton
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
b5b4bb9d66 refactor(client): optimize suspense components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
4c438cf4e4 Add contributing guide to README
Some checks failed
Build Development (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 19:13:45 +08:00
d44eef6bb7 chore(just): do not run frontend install in backend commands
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:30 +08:00
a49450bf9e feat(auth/magic): log to console instead of sending email in debug mode
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:13 +08:00
228d838c37 fix(devenv): use correct just command
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:11:31 +08:00
580402a5c2 feat(devenv)!: integrate all services and tasks
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
d46af028dc chore(client): specify dev server host
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
cdcd05ea52 feat(.zed/settings): set tab size for nix and ts files
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 09:07:19 +00:00
3f05dbe1e6 Rename client-dev to client
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 16:33:46 +08:00
7d76b85055 Expend justfile functions
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 16:23:24 +08:00
65 changed files with 1870 additions and 828 deletions

View File

@@ -1,32 +1 @@
TZ=Asia/Shanghai
SERVER_APPLICATION=nixcn-cms
SERVER_ADDRESS=:8000
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
SERVER_DEBUG_MODE=true
SERVER_FILE_LOGGER=false
DATABASE_TYPE=postgres
DATABASE_HOST=localhost:5432
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
CACHE_HOSTS=localhost:6379
CACHE_MASTER=
CACHE_USERNAME=
CACHE_PASSWORD=
CACHE_DB=0
SEARCH_HOST=localhost
SEARCH_API_KEY=
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
TTL_MAGIC_LINK_TTL=10m
TTL_ACCESS_TTL=15s
TTL_REFRESH_TTL=168h
TTL_CHECKIN_COSE_TTL=10m

View File

@@ -7,7 +7,11 @@
"tab_size": 4, "tab_size": 4,
"format_on_save": "on", "format_on_save": "on",
"languages": { "languages": {
"Nix": {
"tab_size": 2,
},
"TypeScript": { "TypeScript": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",
@@ -16,6 +20,7 @@
], ],
}, },
"TSX": { "TSX": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",
@@ -24,6 +29,7 @@
], ],
}, },
"JavaScript": { "JavaScript": {
"tab_size": 2,
"language_servers": [ "language_servers": [
"typescript-language-server", "typescript-language-server",
"!vtsls", "!vtsls",

View File

@@ -1,2 +1,25 @@
# nixcn-cms # nixcn-cms
## Contribution
1. **Root docs serve the zh-CN version** _[MUST]_
2. **Use sign-off via `git commit -s`** _[MUST]_
3. **Do not modify the `main` branch for any reason** _[MUST]_
4. **Do not omit the commit subject for any reason** _[MUST]_
5. **Describe all changes in the commit message** _[MUST]_
6. **Rebase before submitting patches** _[MUST]_
7. **Commit message written in english** _[MUST]_
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
9. **Split commits for large or multi-part changes** _[OPTION]_
10. **Have fun contributing :)** _[VERY NECESSARY]_
## Toolchain
- Nix
- Devenv
- Direnv
## Notice
1. Client and all nix files use 2 space tab.
2. All Golang files and other configs use 4 space tab.

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",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -24,11 +25,13 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0", "@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6", "@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6", "@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4", "@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -39,6 +42,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@@ -254,6 +258,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], "@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/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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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/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/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-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=="], "@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-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__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=="],
@@ -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-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-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=="], "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=="], "@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-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/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/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=="], "@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=="], "@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=="], "@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=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],

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",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -29,11 +30,13 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0", "@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.27.7",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6", "@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6", "@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4", "@tanstack/zod-adapter": "^1.143.4",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -44,6 +47,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,9 +15,9 @@ import { Button } from '../ui/button';
export function QrDialog( export function QrDialog(
{ eventId }: { eventId: string }, { eventId }: { eventId: string },
) { ) {
const { data } = useCheckinCode(eventId); const [open, setOpen] = useState(false);
return ( return (
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-20"></Button> <Button className="w-20"></Button>
</DialogTrigger> </DialogTrigger>
@@ -27,21 +28,41 @@ export function QrDialog(
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<QrDialogContent checkinCode={data.data.checkin_code} /> <QrSection eventId={eventId} enabled={open} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
function QrDialogContent({ checkinCode }: { checkinCode: string }) { function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
const { data } = useCheckinCode(eventId, enabled);
return data
? (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data={data.data.checkin_code} className="size-60" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
{data.data.checkin_code}
</div>
</DialogFooter>
</>
)
: (
<QrSectionSkeleton />
);
}
function QrSectionSkeleton() {
return ( return (
<> <>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4"> <div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data={checkinCode} className="size-60" /> <QRCode data="114514" className="size-60 blur-xs" />
</div> </div>
<DialogFooter> <DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center"> <div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
{checkinCode} Loading...
</div> </div>
</DialogFooter> </DialogFooter>
</> </>

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import React, { Suspense } from 'react';
export function withFallback<P extends object>(
Component: React.ComponentType<P>,
fallback: ReactNode,
) {
const Wrapped: React.FC<P> = (props) => {
return (
<Suspense fallback={fallback}>
<Component {...props} />
</Suspense>
);
};
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
})`;
return Wrapped;
}

View File

@@ -1,4 +1,5 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile'; import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function LoginForm({ export function LoginForm({
oauthParams,
className, className,
...props ...props
}: React.ComponentProps<'div'>) { }: React.ComponentProps<'div'> & {
oauthParams: AuthorizeSearchParams;
}) {
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null); const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
@@ -28,7 +32,7 @@ export function LoginForm({
event.preventDefault(); event.preventDefault();
const formData = new FormData(formRef.current!); const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string; const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => { mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } }); void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);

View 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>
);
}

View 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>
);
}

View File

@@ -1,13 +1,7 @@
import {
IconDashboard,
IconSettings,
} from '@tabler/icons-react';
import * as React from 'react'; import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react'; import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/nav-main'; import { NavMain } from '@/components/sidebar/nav-main';
import { NavSecondary } from '@/components/nav-secondary'; import { NavSecondary } from '@/components/sidebar/nav-secondary';
import { NavUser } from '@/components/nav-user';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -17,28 +11,8 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
const data = { import { NavUser } from './nav-user';
user: {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
},
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '设置',
url: '#',
icon: IconSettings,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return ( return (
@@ -48,7 +22,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
className="data-[slot=sidebar-menu-button]:!p-1.5" className="data-[slot=sidebar-menu-button]:p-1.5!"
> >
<a href="#"> <a href="#">
<NixOSLogo /> <NixOSLogo />
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain items={data.navMain} /> <NavMain items={navData.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" /> <NavSecondary items={navData.navSecondary} className="mt-auto" />
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavUser /> <NavUser />

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import type { Icon } from '@tabler/icons-react'; import type { Icon } from '@tabler/icons-react';
import * as React from 'react'; import { Link } from '@tanstack/react-router';
import * as React from 'react';
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
@@ -27,12 +28,16 @@ export function NavSecondary({
<SidebarMenu> <SidebarMenu>
{items.map(item => ( {items.map(item => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild> <Link to={item.url}>
<a href={item.url}> {({ isActive }) => {
<item.icon /> return (
<span>{item.title}</span> <SidebarMenuButton isActive={isActive} tooltip={item.title}>
</a> <item.icon />
</SidebarMenuButton> <span>{item.title}</span>
</SidebarMenuButton>
);
}}
</Link>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>

View File

@@ -24,8 +24,10 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo'; import { useUserInfo } from '@/hooks/data/useUserInfo';
import { useLogout } from '@/hooks/useLogout'; import { useLogout } from '@/hooks/useLogout';
import { withFallback } from '../hoc/with-fallback';
import { Skeleton } from '../ui/skeleton';
export function NavUser() { function NavUser_() {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const { data: user } = useUserInfo(); const { data: user } = useUserInfo();
const { logout } = useLogout(); const { logout } = useLogout();
@@ -83,3 +85,20 @@ export function NavUser() {
</SidebarMenu> </SidebarMenu>
); );
} }
function NavUserSkeleton() {
return (
<SidebarMenuButton
size="lg"
>
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex flex-col flex-1 gap-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
);
}
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);

View File

@@ -1,7 +1,18 @@
import { useRouterState } from '@tanstack/react-router';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { navData } from '@/lib/navData';
export function SiteHeader() { export function SiteHeader() {
const pathname = useRouterState({ select: state => state.location.pathname });
const allNavItems = [...navData.navMain, ...navData.navSecondary];
const currentTitle
= allNavItems.find(item =>
item.url === '/'
? pathname === '/'
: pathname.startsWith(item.url),
)?.title ?? '工作台';
return ( return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"> <header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6"> <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -10,7 +21,7 @@ export function SiteHeader() {
orientation="vertical" orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4" className="mx-2 data-[orientation=vertical]:h-4"
/> />
<h1 className="text-base font-medium"></h1> <h1 className="text-base font-medium">{currentTitle}</h1>
</div> </div>
</header> </header>
); );

View File

@@ -190,7 +190,7 @@ function FieldError({
}: React.ComponentProps<'div'> & { }: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>; errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(async () => { const content = useMemo(() => {
if (children) { if (children) {
return children; return children;
} }

View File

@@ -0,0 +1,9 @@
import { Skeleton } from '../ui/skeleton';
export function CardSkeleton() {
return (
<Skeleton
className="gap-6 rounded-xl py-6 h-full"
/>
);
}

View File

@@ -2,29 +2,31 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useUserInfo } from '@/hooks/data/useUserInfo'; import { useUserInfo } from '@/hooks/data/useUserInfo';
import { QrDialog } from '../checkin/qr-dialog'; import { QrDialog } from '../checkin/qr-dialog';
import { withFallback } from '../hoc/with-fallback';
import { CardSkeleton } from './card-skeleton';
export function CheckinCard() { function CheckinCard_() {
const { data } = useUserInfo(); const { data } = useUserInfo();
return ( return (
<> <Card className="@container/card">
<Card className="@container/card"> <CardHeader>
<CardHeader> <CardDescription></CardDescription>
<CardDescription></CardDescription> <CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl"> {data.checkin !== null ? '已签到' : '未签到'}
{data.checkin !== null ? '已签到' : '未签到'} {data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>} </CardTitle>
</CardTitle> <CardAction>
<CardAction> <Badge variant="outline">Day 1</Badge>
<Badge variant="outline">Day 1</Badge> </CardAction>
</CardAction> </CardHeader>
</CardHeader> <CardFooter className="flex-col items-start gap-1.5 text-sm">
<CardFooter className="flex-col items-start gap-1.5 text-sm"> <QrDialog
<QrDialog eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11" >
> </QrDialog>
</QrDialog> </CardFooter>
</CardFooter> </Card>
</Card>
</>
); );
} }
export const CheckinCard = withFallback(CheckinCard_, <CardSkeleton />);

View File

@@ -1,8 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios'; import { axiosClient } from '@/lib/axios';
export function useCheckinCode(eventId: string) { export function useCheckinCode(eventId: string, enabled: boolean) {
return useSuspenseQuery({ return useQuery({
queryKey: ['getCheckinCode', eventId], queryKey: ['getCheckinCode', eventId],
queryFn: async () => { queryFn: async () => {
return axiosClient.get<{ return axiosClient.get<{
@@ -13,5 +13,6 @@ export function useCheckinCode(eventId: string) {
}, },
}); });
}, },
enabled,
}); });
} }

View File

@@ -1,7 +1,8 @@
import type { AuthorizeSearchParams } from '@/routes/authorize';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios'; import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload { interface GetMagicLinkPayload extends AuthorizeSearchParams {
email: string; email: string;
turnstile_token: string; turnstile_token: string;
} }
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
export function useGetMagicLink() { export function useGetMagicLink() {
return useMutation({ return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => { mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<object>('/auth/magic', payload); return axiosClient.post<{ status: string }>('/auth/magic', payload);
}, },
}); });
} }

View File

@@ -17,5 +17,6 @@ export function useUserInfo() {
>('/user/info'); >('/user/info');
return response.data; return response.data;
}, },
staleTime: 10 * 60 * 1000,
}); });
} }

View File

@@ -7,7 +7,7 @@ export function useLogout() {
const logout = useCallback(() => { const logout = useCallback(() => {
clearTokens(); clearTokens();
void navigate({ to: '/login' }); void navigate({ to: '/authorize' });
}, [navigate]); }, [navigate]);
return { logout }; return { logout };

21
client/src/lib/navData.ts Normal file
View 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
View 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(/=+$/, '');
}

View File

@@ -29,6 +29,12 @@ export function clearTokens() {
setRefreshToken(''); setRefreshToken('');
} }
export async function doSetTokenByCode(code: string) {
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
setToken(data.access_token);
setRefreshToken(data.refresh_token);
}
export async function doRefreshToken() { export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() }); return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
} }

View File

@@ -9,19 +9,26 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TokenRouteImport } from './routes/token'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent' import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as LoginRouteImport } from './routes/login' import { Route as AuthorizeRouteImport } from './routes/authorize'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout' import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index' import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
const TokenRoute = TokenRouteImport.update({
id: '/token',
path: '/token',
getParentRoute: () => rootRouteImport,
} as any)
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({ const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent', id: '/magicLinkSent',
path: '/magicLinkSent', path: '/magicLinkSent',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({ const AuthorizeRoute = AuthorizeRouteImport.update({
id: '/login', id: '/authorize',
path: '/login', path: '/authorize',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({ const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => SidebarLayoutRoute, getParentRoute: () => SidebarLayoutRoute,
} as any) } as any)
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => SidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute '/': typeof SidebarLayoutIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/profile': typeof SidebarLayoutProfileRoute
'/': typeof SidebarLayoutIndexRoute '/': typeof SidebarLayoutIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren '/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/login': typeof LoginRoute '/authorize': typeof AuthorizeRoute
'/magicLinkSent': typeof MagicLinkSentRoute '/magicLinkSent': typeof MagicLinkSentRoute
'/token': typeof TokenRoute
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute '/_sidebarLayout/': typeof SidebarLayoutIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/magicLinkSent' | '/' fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/login' | '/magicLinkSent' | '/' to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
id: id:
| '__root__' | '__root__'
| '/_sidebarLayout' | '/_sidebarLayout'
| '/login' | '/authorize'
| '/magicLinkSent' | '/magicLinkSent'
| '/token'
| '/_sidebarLayout/profile'
| '/_sidebarLayout/' | '/_sidebarLayout/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute AuthorizeRoute: typeof AuthorizeRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute MagicLinkSentRoute: typeof MagicLinkSentRoute
TokenRoute: typeof TokenRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/token': {
id: '/token'
path: '/token'
fullPath: '/token'
preLoaderRoute: typeof TokenRouteImport
parentRoute: typeof rootRouteImport
}
'/magicLinkSent': { '/magicLinkSent': {
id: '/magicLinkSent' id: '/magicLinkSent'
path: '/magicLinkSent' path: '/magicLinkSent'
@@ -79,11 +107,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MagicLinkSentRouteImport preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': { '/authorize': {
id: '/login' id: '/authorize'
path: '/login' path: '/authorize'
fullPath: '/login' fullPath: '/authorize'
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof AuthorizeRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_sidebarLayout': { '/_sidebarLayout': {
@@ -100,14 +128,23 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SidebarLayoutIndexRouteImport preLoaderRoute: typeof SidebarLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute parentRoute: typeof SidebarLayoutRoute
} }
'/_sidebarLayout/profile': {
id: '/_sidebarLayout/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
parentRoute: typeof SidebarLayoutRoute
}
} }
} }
interface SidebarLayoutRouteChildren { interface SidebarLayoutRouteChildren {
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
} }
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = { const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute, SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
} }
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren, SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute, AuthorizeRoute: AuthorizeRoute,
MagicLinkSentRoute: MagicLinkSentRoute, MagicLinkSentRoute: MagicLinkSentRoute,
TokenRoute: TokenRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -4,7 +4,24 @@ import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import '@/index.css'; import '@/index.css';
const queryClient = new QueryClient(); const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// eslint-disable-next-line ts/no-unsafe-assignment
const status
// eslint-disable-next-line ts/no-unsafe-member-access
= error?.response?.status ?? error?.status;
if (status >= 400 && status < 500) {
return false;
}
return failureCount < 3;
},
},
},
});
function RootLayout() { function RootLayout() {
return ( return (

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/sidebar/app-sidebar';
import { SiteHeader } from '@/components/site-header'; import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -7,24 +7,22 @@ export const Route = createFileRoute('/_sidebarLayout/')({
loader: async () => { loader: async () => {
if (!hasToken()) { if (!hasToken()) {
throw redirect({ throw redirect({
to: '/login', to: '/authorize',
}); });
} }
}, },
}); });
function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<CheckinCard />
</div>
);
}
function Index() { function Index() {
return ( return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards /> {/* Section Cards */}
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<CheckinCard />
</div>
</div> </div>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>;
}

View File

@@ -23,8 +23,10 @@ export default defineConfig({
}, },
server: { server: {
proxy: { proxy: {
'/api': 'http://10.0.0.10:8000', '/api': 'http://10.0.0.250:8000',
}, },
allowedHosts: ['dev.sne.moe'], host: '0.0.0.0',
port: 5173,
allowedHosts: ['test.sne.moe'],
}, },
}); });

View File

@@ -20,13 +20,19 @@ search:
host: 127.0.0.1 host: 127.0.0.1
api_key: "" api_key: ""
email: email:
resend_api_key: abc host:
port:
username:
password:
security:
insecure_skip_verify:
from: from:
secrets: secrets:
jwt_secret: example jwt_secret: example
turnstile_secret: example turnstile_secret: example
client_secret_key: example
ttl: ttl:
magic_link_ttl: 10m auth_code_ttl: 10m
jwt_ttl: 15s access_ttl: 15s
refresh_ttl: 7d refresh_ttl: 168h
checkin_code_ttl: 5m checkin_code_ttl: 10m

View File

@@ -13,9 +13,9 @@ type config struct {
type server struct { type server struct {
Application string `yaml:"application"` Application string `yaml:"application"`
Address string `yaml:"address"` Address string `yaml:"address"`
ExternalUrl string `yaml:"external_url"`
DebugMode string `yaml:"debug_mode"` DebugMode string `yaml:"debug_mode"`
FileLogger string `yaml:"file_logger"` FileLogger string `yaml:"file_logger"`
JwtSecret string `yaml:"jwt_secret"`
} }
type database struct { type database struct {
@@ -40,17 +40,23 @@ type search struct {
} }
type email struct { type email struct {
ResendApiKey string `yaml:"resend_api_key"` Host string `yaml:"host"`
From string `yaml:"from"` 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 { type secrets struct {
JwtSecret string `yaml:"jwt_secret"` JwtSecret string `yaml:"jwt_secret"`
TurnstileSecret string `yaml:"turnstile_secret"` TurnstileSecret string `yaml:"turnstile_secret"`
ClientSecretKey string `yaml:"client_secret_key"`
} }
type ttl struct { type ttl struct {
MagicLinkTTL string `yaml:"magic_link_ttl"` AuthCodeTTL string `yaml:"auth_code_ttl"`
AccessTTL string `yaml:"access_ttl"` AccessTTL string `yaml:"access_ttl"`
RefreshTTL string `yaml:"refresh_ttl"` RefreshTTL string `yaml:"refresh_ttl"`
CheckinCodeTTL string `yaml:"checkin_code_ttl"` CheckinCodeTTL string `yaml:"checkin_code_ttl"`

263
data/attendance.go Normal file
View File

@@ -0,0 +1,263 @@
package data
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
"github.com/meilisearch/meilisearch-go"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Attendance struct {
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
AttendanceId uuid.UUID `json:"attendance_id" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
Role string `json:"role" gorm:"type:varchar(255);not null"`
CheckinAt time.Time `json:"checkin_at"`
}
type AttendanceSearchDoc struct {
AttendanceId string `json:"attendance_id"`
EventId string `json:"event_id"`
UserId string `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) {
var checkin Attendance
err := Database.
Where("user_id = ? AND event_id = ?", userId, eventId).
First(&checkin).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &checkin, err
}
type AttendanceUsers struct {
UserId uuid.UUID `json:"user_id"`
Role string `json:"role"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) {
var result []AttendanceUsers
err := Database.
Model(&Attendance{}).
Select("user_id, checkin_at").
Where("event_id = ?", eventID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
type AttendanceEvent struct {
EventId uuid.UUID `json:"event_id"`
CheckinAt time.Time `json:"checkin_at"`
}
func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) {
var result []AttendanceEvent
err := Database.
Model(&Attendance{}).
Select("event_id, checkin_at").
Where("user_id = ?", userID).
Order("checkin_at ASC").
Scan(&result).Error
return &result, err
}
func (self *Attendance) Create() error {
self.UUID = uuid.New()
self.AttendanceId = uuid.New()
// DB transaction for strong consistency
err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
return nil
})
if err != nil {
return err
}
if err := self.UpdateSearchIndex(); err != nil {
return err
}
return nil
}
func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) {
var attendance Attendance
err := Database.Transaction(func(tx *gorm.DB) error {
// Lock the row for update
if err := tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error; err != nil {
return err
}
updates := map[string]any{}
if checkinTime != nil {
updates["checkin_at"] = *checkinTime
}
if len(updates) == 0 {
return nil
}
if err := tx.
Model(&attendance).
Updates(updates).Error; err != nil {
return err
}
// Reload to ensure struct is up to date
return tx.
Where("attendance_id = ?", attendanceId).
First(&attendance).Error
})
if err != nil {
return nil, err
}
// Sync to MeiliSearch (eventual consistency)
if err := attendance.UpdateSearchIndex(); err != nil {
return nil, err
}
return &attendance, nil
}
func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "event_id = \"" + eventID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
index := MeiliSearch.Index("attendance")
return index.Search("", &meilisearch.SearchRequest{
Filter: "user_id = \"" + userID + "\"",
Sort: []string{"checkin_at:asc"},
})
}
func (self *Attendance) UpdateSearchIndex() error {
doc := AttendanceSearchDoc{
AttendanceId: self.AttendanceId.String(),
EventId: self.EventId.String(),
UserId: self.UserId.String(),
CheckinAt: self.CheckinAt,
}
index := MeiliSearch.Index("attendance")
primaryKey := "attendance_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Attendance) DeleteSearchIndex() error {
index := MeiliSearch.Index("attendance")
_, err := index.DeleteDocument(self.AttendanceId.String(), nil)
return err
}
func (self *Attendance) GenCheckinCode(eventId uuid.UUID) (*string, error) {
ctx := context.Background()
ttl := viper.GetDuration("ttl.checkin_code_ttl")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
ok, err := Redis.SetNX(
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
}
}
func (self *Attendance) VerifyCheckinCode(checkinCode string) error {
ctx := context.Background()
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
if err != nil {
return errors.New("invalid or expired checkin code")
}
// Expected format: user_id:<uuid>:event_id:<uuid>
parts := strings.Split(val, ":")
if len(parts) != 4 {
return errors.New("invalid checkin code format")
}
userIdStr := parts[1]
eventIdStr := parts[3]
userId, err := uuid.Parse(userIdStr)
if err != nil {
return err
}
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
return err
}
attendanceData, err := self.GetAttendance(userId, eventId)
if err != nil {
return err
}
time := time.Now()
_, err = self.Update(attendanceData.AttendanceId, &time)
if err != nil {
return err
}
return nil
}

91
data/client.go Normal file
View File

@@ -0,0 +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")
}

View File

@@ -35,7 +35,7 @@ func Init() {
} }
// Auto migrate // Auto migrate
err = db.AutoMigrate(&User{}, &Event{}) err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
if err != nil { if err != nil {
log.Error("[Database] Error migrating database: ", err) log.Error("[Database] Error migrating database: ", err)
} }

View File

@@ -1,26 +1,22 @@
package data package data
import ( import (
"errors"
"slices"
"time" "time"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type Event struct { type Event struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"` Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"` UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"` EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
Name string `json:"name" gorm:"type:varchar(255);index;not null"` Name string `json:"name" gorm:"type:varchar(255);index;not null"`
StartTime time.Time `json:"start_time" gorm:"index"` Type string `json:"type" gotm:"type:varchar(255);index;not null"`
EndTime time.Time `json:"end_time" gorm:"index"` StartTime time.Time `json:"start_time" gorm:"index"`
JoinedUsers datatypes.JSONSlice[uuid.UUID] `json:"joined_users"` EndTime time.Time `json:"end_time" gorm:"index"`
} }
type EventSearchDoc struct { type EventSearchDoc struct {
@@ -30,93 +26,71 @@ type EventSearchDoc struct {
EndTime time.Time `json:"end_time"` EndTime time.Time `json:"end_time"`
} }
func (self *Event) GetEventById(eventId uuid.UUID) error { func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
return Database.Transaction(func(tx *gorm.DB) error { var event Event
if err := tx.Where("event_id = ?", eventId).First(&self).Error; err != nil {
return err err := Database.
Where("event_id = ?", eventId).
First(&event).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
} }
return nil return nil, err
}) }
return &event, nil
} }
func (self *Event) UpdateEventById(eventId uuid.UUID) error { func (self *Event) UpdateEventById(eventId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error { // DB transaction
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Updates(&self).Error; err != nil { if err := Database.Transaction(func(tx *gorm.DB) error {
return err // Update by business key
} if err := tx.
Model(&Event{}).
// Update event to document index
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
docPrimaryKey := "event_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil
})
}
func (self *Event) CreateEvent() error {
if self.UUID == uuid.Nil {
self.UUID = uuid.New()
}
if self.EventId == uuid.Nil {
self.EventId = uuid.New()
}
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&self).Error; err != nil {
return err
}
// Add event to document index
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
docPrimaryKey := "event_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.AddDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil
})
}
func (self *Event) UserJoinEvent(userId, eventId uuid.UUID) error {
return Database.Transaction(func(tx *gorm.DB) error {
var event Event
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("event_id = ?", eventId). Where("event_id = ?", eventId).
First(&event).Error; err != nil { Updates(self).Error; err != nil {
return err return err
} }
// Check if user already joined // Reload to ensure struct is fresh
if slices.Contains(event.JoinedUsers, userId) { return tx.
return errors.New("user already joined") Where("event_id = ?", eventId).
} First(self).Error
}); err != nil {
return err
}
// Add user to list // Sync search index
event.JoinedUsers = append(event.JoinedUsers, userId) if err := self.UpdateSearchIndex(); err != nil {
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Update("joined_users", event.JoinedUsers).Error; err != nil { return err
}
return nil
}
func (self *Event) Create() error {
self.UUID = uuid.New()
self.EventId = uuid.New()
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil {
return err return err
} }
*self = event
return nil return nil
}) }); err != nil {
return err
}
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
return nil
} }
func (self *Event) GetFullTable() (*[]Event, error) { func (self *Event) GetFullTable() (*[]Event, error) {
@@ -130,6 +104,8 @@ func (self *Event) GetFullTable() (*[]Event, error) {
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) { func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
index := MeiliSearch.Index("event") index := MeiliSearch.Index("event")
// Fast read from MeiliSearch (no DB involved)
result, err := index.Search("", &meilisearch.SearchRequest{ result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
@@ -137,9 +113,38 @@ func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
var list []EventSearchDoc var list []EventSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil { if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err return nil, err
} }
return &list, nil return &list, nil
} }
func (self *Event) UpdateSearchIndex() error {
doc := EventSearchDoc{
EventId: self.EventId.String(),
Name: self.Name,
StartTime: self.StartTime,
EndTime: self.EndTime,
}
index := MeiliSearch.Index("event")
primaryKey := "event_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
return err
}
return nil
}
func (self *Event) DeleteSearchIndex() error {
index := MeiliSearch.Index("event")
_, err := index.DeleteDocument(self.EventId.String(), nil)
return err
}

View File

@@ -1,20 +1,10 @@
package data package data
import ( import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"github.com/spf13/viper"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// Permission Level // Permission Level
@@ -24,16 +14,15 @@ import (
// Super User: 30 // Super User: 30
type User struct { type User struct {
Id uint `json:"id" gorm:"primarykey;autoincrement"` Id uint `json:"id" gorm:"primarykey;autoincrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"` UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"` UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"`
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"` Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
Type string `json:"type" gorm:"type:varchar(32);index;not null"` Nickname string `json:"nickname"`
Nickname string `json:"nickname"` Subtitle string `json:"subtitle"`
Subtitle string `json:"subtitle"` Avatar string `json:"avatar"`
Avatar string `json:"avatar"` Bio string `json:"bio" gorm:"type:text"`
Checkin datatypes.JSONMap `json:"checkin"` PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
} }
type UserSearchDoc struct { type UserSearchDoc struct {
@@ -46,88 +35,61 @@ type UserSearchDoc struct {
PermissionLevel uint `json:"permission_level"` PermissionLevel uint `json:"permission_level"`
} }
func (self *User) GetByEmail(email string) error { func (self *User) GetByEmail(email string) (*User, error) {
if err := Database.Where("email = ?", email).First(&self).Error; err != nil { var user User
return err
err := Database.
Where("email = ?", email).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
} }
return nil
return &user, nil
} }
func (self *User) GetByUserId(userId uuid.UUID) error { func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil { var user User
return err
err := Database.
Where("user_id = ?", userId).
First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
} }
return nil
}
func (self *User) UpdateCheckin(userId, eventId uuid.UUID, time time.Time) error { return &user, err
return Database.Transaction(func(tx *gorm.DB) error {
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", userId).
First(self).Error; err != nil {
return err // if error then rollback
}
self.Checkin = datatypes.JSONMap{eventId.String(): time}
if err := tx.Save(self).Error; err != nil {
return err // rollback
}
// Update user to document index
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Type: self.Type,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
}
index := MeiliSearch.Index("user")
docPrimaryKey := "user_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.UpdateDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil // commit
})
} }
func (self *User) Create() error { func (self *User) Create() error {
return Database.Transaction(func(tx *gorm.DB) error { self.UUID = uuid.New()
if self.UUID == uuid.Nil { self.UserId = uuid.New()
self.UUID = uuid.New()
}
if self.UserId == uuid.Nil {
self.UserId = uuid.New()
}
// DB transaction only
if err := Database.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(self).Error; err != nil { if err := tx.Create(self).Error; err != nil {
return err return err
} }
// Create user to document index
doc := UserSearchDoc{
UserId: self.UserId.String(),
Email: self.Email,
Type: self.Type,
Nickname: self.Nickname,
Subtitle: self.Subtitle,
Avatar: self.Avatar,
PermissionLevel: self.PermissionLevel,
}
index := MeiliSearch.Index("user")
docPrimaryKey := "user_id"
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
if _, err := index.AddDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
return err
}
return nil return nil
}) }); err != nil {
return err
}
// Search index (eventual consistency)
if err := self.UpdateSearchIndex(); err != nil {
// TODO: async retry / log
return err
}
return nil
} }
func (self *User) UpdateByUserID(userId uuid.UUID) error { func (self *User) UpdateByUserID(userId uuid.UUID) error {
@@ -150,6 +112,8 @@ func (self *User) GetFullTable() (*[]User, error) {
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) { func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
index := MeiliSearch.Index("user") index := MeiliSearch.Index("user")
// Fast read from MeiliSearch, no DB involved
result, err := index.Search("", &meilisearch.SearchRequest{ result, err := index.Search("", &meilisearch.SearchRequest{
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
@@ -157,75 +121,43 @@ func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var list []UserSearchDoc var list []UserSearchDoc
if err := mapstructure.Decode(result.Hits, &list); err != nil { if err := mapstructure.Decode(result.Hits, &list); err != nil {
return nil, err return nil, err
} }
return &list, nil return &list, nil
} }
func (self *User) GenCheckinCode(eventId uuid.UUID) (*string, error) { func (self *User) UpdateSearchIndex() error {
ctx := context.Background() doc := UserSearchDoc{
ttl := viper.GetDuration("ttl.checkin_code_ttl") UserId: self.UserId.String(),
rng := rand.New(rand.NewSource(time.Now().UnixNano())) Email: self.Email,
Nickname: self.Nickname,
for { Subtitle: self.Subtitle,
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000) Avatar: self.Avatar,
ok, err := Redis.SetNX( PermissionLevel: self.PermissionLevel,
ctx,
"checkin_code:"+code,
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
ttl,
).Result()
if err != nil {
return nil, err
}
if ok {
return &code, nil
}
} }
index := MeiliSearch.Index("user")
primaryKey := "user_id"
opts := &meilisearch.DocumentOptions{
PrimaryKey: &primaryKey,
}
if _, err := index.UpdateDocuments(
[]UserSearchDoc{doc},
opts,
); err != nil {
return err
}
return nil
} }
func (self *User) VerifyCheckinCode(checkinCode string) (*uuid.UUID, error) { func (self *User) DeleteSearchIndex() error {
ctx := context.Background() index := MeiliSearch.Index("user")
_, err := index.DeleteDocument(self.UserId.String(), nil)
result := Redis.Get(ctx, "checkin_code:"+checkinCode).String() return err
if result == "" {
return nil, errors.New("invalid or expired checkin code")
}
split := strings.Split(result, ":")
if len(split) < 2 {
return nil, errors.New("invalid checkin code format")
}
userId := split[0]
eventId := split[1]
var returnedUserId uuid.UUID
err := Database.Transaction(func(tx *gorm.DB) error {
checkinData := map[string]interface{}{
eventId: time.Now(),
}
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(map[string]interface{}{
"checkin": checkinData,
}).Error; err != nil {
return err
}
parsedUserId, err := uuid.Parse(userId)
if err != nil {
return err
}
returnedUserId = parsedUserId
return nil
})
if err != nil {
return nil, err
}
return &returnedUserId, nil
} }

View File

@@ -1,12 +1,14 @@
{ pkgs, config, ... }: { pkgs, config, ... }:
{ {
env.GREET = "devenv"; process.managers.process-compose = {
settings.log_level = "info";
};
packages = [ packages = [
pkgs.git pkgs.git
pkgs.bun
pkgs.just pkgs.just
pkgs.watchexec
]; ];
dotenv = { dotenv = {
@@ -17,49 +19,55 @@
]; ];
}; };
languages.go = { languages = {
enable = true; go = {
version = "1.25.5"; enable = true;
version = "1.25.5";
};
javascript.enable = true;
javascript.bun.enable = true;
}; };
services.caddy = { processes = {
enable = true; vite = {
dataDir = "${config.env.DEVENV_STATE}/caddy"; exec = "bun run dev";
config = '' cwd = "./client";
:8080 { };
handle /api/* { backend.exec = "just dev-back";
reverse_proxy 127.0.0.1:8000 };
services = {
caddy = {
enable = true;
dataDir = "${config.env.DEVENV_STATE}/caddy";
config = ''
:8080 {
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
handle {
reverse_proxy 127.0.0.1:5173
}
}
'';
};
redis = {
enable = true;
};
postgres = {
enable = true;
createDatabase = true;
listen_addresses = "127.0.0.1";
initialDatabases = [
{
name = "postgres";
user = "postgres";
pass = "postgres";
} }
handle { ];
root * ${config.env.DEVENV_ROOT}/.outputs/static };
encode zstd gzip meilisearch = {
@assets path /assets/* enable = true;
header @assets Cache-Control "public, max-age=31536000, immutable" };
try_files {path} /index.html
file_server
}
}
'';
};
services.redis = {
enable = true;
};
services.postgres = {
enable = true;
createDatabase = true;
listen_addresses = "127.0.0.1";
initialDatabases = [
{
name = "postgres";
user = "postgres";
pass = "postgres";
}
];
};
services.meilisearch = {
enable = true;
}; };
} }

2
go.mod
View File

@@ -64,6 +64,8 @@ require (
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // 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/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/postgres v1.6.0 // indirect

4
go.sum
View File

@@ -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= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/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= gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=

View File

@@ -0,0 +1,208 @@
package cryptography
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := io.ReadFull(rand.Reader, b)
return b, err
}
func normalizeKey(key []byte) ([]byte, error) {
switch len(key) {
case 16, 24, 32:
return key, nil
default:
return nil, errors.New("AES key length must be 16, 24, or 32 bytes")
}
}
func AESGCMEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := randomBytes(gcm.NonceSize())
if err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
out := append(nonce, ciphertext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESGCMDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, errors.New("invalid padding")
}
padding := int(data[length-1])
if padding == 0 || padding > length {
return nil, errors.New("invalid padding")
}
return data[:length-padding], nil
}
func AESCBCEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plaintext = pkcs7Pad(plaintext, block.BlockSize())
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCBCDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(data, data)
return pkcs7Unpad(data)
}
func AESCFBEncrypt(plaintext, key []byte) (string, error) {
key, err := normalizeKey(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv, err := randomBytes(block.BlockSize())
if err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(plaintext, plaintext)
out := append(iv, plaintext...)
return base64.RawURLEncoding.EncodeToString(out), nil
}
func AESCFBDecrypt(encoded string, key []byte) ([]byte, error) {
key, err := normalizeKey(key)
if err != nil {
return nil, err
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := data[:block.BlockSize()]
data = data[block.BlockSize():]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(data, data)
return data, nil
}

View File

@@ -0,0 +1,22 @@
package cryptography
import "golang.org/x/crypto/bcrypt"
func BcryptHash(input string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(input),
bcrypt.DefaultCost,
)
if err != nil {
return "", err
}
return string(hash), nil
}
func BcryptVerify(input string, hashed string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hashed),
[]byte(input),
)
return err == nil
}

View File

@@ -7,22 +7,34 @@ exec_path := join(output_dir, project_name)
go_cmd := `realpath $(which go)` go_cmd := `realpath $(which go)`
bun_cmd := `realpath $(which bun)` bun_cmd := `realpath $(which bun)`
default: install clean build run default: install clean build-back build-client run-back
backend: clean build-back run-back
install: install:
cd {{ client_dir }} && {{ bun_cmd }} install cd {{ client_dir }} && {{ bun_cmd }} install
clean: clean:
rm -rf {{ output_dir }} mkdir -p .outputs
mkdir -p {{ output_dir }} find .outputs -mindepth 1 ! -path .outputs/config.yaml -exec rm -rf {} +
cp {{ join(project_dir, "config.default.yaml") }} {{ join(output_dir, "config.yaml") }}
build: client:
{{ go_cmd }} build -o {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} {{ server_enrty }} cd {{ client_dir }} && {{ bun_cmd }} dev
build-client:
cd {{ client_dir }} && {{ bun_cmd }} run build --outDir {{ join(output_dir, "static") }} cd {{ client_dir }} && {{ bun_cmd }} run build --outDir {{ join(output_dir, "static") }}
run: build-back:
{{ go_cmd }} build -o {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} {{ server_enrty }}
run-back:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }} cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}{{ if os() == "windows" { ".exe" } else { "" } }}
test: test-back:
cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./... cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} GO_ENV=test go test -C .. ./...
dev-back: clean
watchexec -r -e go,yaml,tpl -i '.devenv/**' -i '.direnv/**' -i 'client/**' -i 'vendor/**' 'go build -o {{ join(output_dir, "nixcn-cms") }} . && cd {{ output_dir }} && CONFIG_PATH={{ output_dir }} {{ exec_path }}'
dev:
devenv up --verbose

View File

@@ -1,57 +1,31 @@
package middleware package middleware
import ( import (
"net/http" "nixcn-cms/pkgs/authtoken"
"strings"
"nixcn-cms/internal/cryptography"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
) )
func JWTAuth() gin.HandlerFunc { func JWTAuth(required bool) gin.HandlerFunc {
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
return func(c *gin.Context) { return func(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
if auth == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ authtoken := new(authtoken.Token)
"error": "missing Authorization header", uid, err := authtoken.HeaderVerify(auth)
}) if err != nil {
c.JSON(401, gin.H{"status": err.Error()})
c.Abort()
return return
} }
// Split header to 2 if required == true && uid == "" {
parts := strings.SplitN(auth, " ", 2) c.JSON(401, gin.H{"status": "unauthorized"})
if len(parts) != 2 || parts[0] != "Bearer" { c.Abort()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid Authorization header format",
})
return return
} }
tokenStr := parts[1] c.Set("user_id", uid)
// Verify access token
claims := &cryptography.JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
c.Set("user_id", claims.UserID)
c.Next() c.Next()
} }
} }

View File

@@ -1,4 +1,4 @@
package magiclink package authcode
import ( import (
"crypto/rand" "crypto/rand"
@@ -19,35 +19,35 @@ var (
) )
// Generate magic token // Generate magic token
func NewMagicToken(email string) (string, error) { func NewAuthCode(email string) (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", err return "", err
} }
token := base64.RawURLEncoding.EncodeToString(b) code := base64.RawURLEncoding.EncodeToString(b)
store.Store(token, Token{ store.Store(code, Token{
Email: email, 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 // Verify magic token
func VerifyMagicToken(token string) (string, bool) { func VerifyAuthCode(code string) (string, bool) {
val, ok := store.Load(token) val, ok := store.Load(code)
if !ok { if !ok {
return "", false return "", false
} }
t := val.(Token) t := val.(Token)
if time.Now().After(t.ExpiresAt) { if time.Now().After(t.ExpiresAt) {
store.Delete(token) store.Delete(code)
return "", false return "", false
} }
store.Delete(token) store.Delete(code)
return t.Email, true return t.Email, true
} }

View File

@@ -1,11 +1,13 @@
package cryptography package authtoken
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"nixcn-cms/data" "nixcn-cms/data"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -187,3 +189,34 @@ func (self *Token) RevokeRefreshToken(refreshToken string) error {
return err return err
} }
func (self *Token) HeaderVerify(header string) (string, error) {
if header == "" {
return "", nil
}
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
// Split header to 2
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("invalid Authorization header format")
}
tokenStr := parts[1]
// Verify access token
claims := &JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
return "", errors.New("invalid or expired token")
}
return claims.UserID.String(), nil
}

70
pkgs/email/email.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -1,9 +1,14 @@
package auth package auth
import "github.com/gin-gonic/gin" import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.POST("/magic", RequestMagicLink) r.GET("/redirect", Redirect, middleware.JWTAuth(false))
r.GET("/magic/verify", VerifyMagicLink) r.POST("/magic", Magic)
r.POST("/refresh", Refresh) r.POST("/refresh", Refresh)
r.POST("/token", Token)
} }

View File

@@ -1,28 +1,26 @@
package auth package auth
import ( import (
"nixcn-cms/data" "net/url"
"nixcn-cms/internal/cryptography" "nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email" "nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/magiclink"
"nixcn-cms/pkgs/turnstile" "nixcn-cms/pkgs/turnstile"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
type MagicLinkRequest struct { type MagicRequest struct {
Email string `json:"email" binding:"required,email"` ClientId string `json:"client_id"`
TurnstileToken string `json:"turnstile_token" binding:"required"` RedirectUri string `json:"redirect_uri"`
State string `json:"state"`
Email string `json:"email"`
TurnstileToken string `json:"turnstile_token"`
} }
func RequestMagicLink(c *gin.Context) { func Magic(c *gin.Context) {
// Parse request // Parse request
var req MagicLinkRequest var req MagicRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"}) c.JSON(400, gin.H{"error": "invalid request"})
return return
@@ -35,82 +33,42 @@ func RequestMagicLink(c *gin.Context) {
return return
} }
// Generate magic token code, err := authcode.NewAuthCode(req.Email)
token, err := magiclink.NewMagicToken(req.Email)
if err != nil { if err != nil {
c.JSON(500, gin.H{"error": "internal error"}) c.JSON(500, gin.H{"status": "code gen failed"})
return
} }
link := viper.GetString("server.external_url") + "/login?ticket=" + token externalUrl := viper.GetString("server.external_url")
url, err := url.Parse(externalUrl)
// Send email using resend
resend, err := email.NewResendClient()
if err != nil { if err != nil {
log.Error(err) c.JSON(500, gin.H{"status": "invalid external url"})
c.JSON(500, gin.H{"status": "invilad email config"}) }
return
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
emailClient, err := email.NewSMTPClient()
if err != nil {
c.JSON(500, gin.H{"status": "invalid email config"})
return
}
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="+url.String()+">"+url.String()+"</a>",
)
} }
resend.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="+link+">"+link+"</a>",
)
c.JSON(200, gin.H{"status": "magic link sent"}) 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 := magiclink.VerifyMagicToken(magicToken)
if !ok {
c.JSON(401, gin.H{"error": "invalid or expired token"})
return
}
// Verify if user exists
user := new(data.User)
err := user.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.Type = "Normal"
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,
})
}

128
service/auth/redirect.go Normal file
View File

@@ -0,0 +1,128 @@
package auth
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())
}

View File

@@ -1,7 +1,7 @@
package auth package auth
import ( import (
"nixcn-cms/internal/cryptography" "nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
return return
} }
JwtTool := cryptography.Token{ JwtTool := authtoken.Token{
Application: viper.GetString("server.application"), Application: viper.GetString("server.application"),
} }
@@ -30,6 +30,7 @@ func Refresh(c *gin.Context) {
refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken) refresh, err := JwtTool.RenewRefreshToken(req.RefreshToken)
if err != nil { if err != nil {
c.JSON(500, gin.H{"statis": "error renew refresh token"}) c.JSON(500, gin.H{"statis": "error renew refresh token"})
return
} }
c.JSON(200, gin.H{ c.JSON(200, gin.H{

52
service/auth/token.go Normal file
View File

@@ -0,0 +1,52 @@
package auth
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,
})
}

View File

@@ -7,6 +7,6 @@ import (
) )
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth()) r.Use(middleware.JWTAuth(true))
r.GET("/info", Info) r.GET("/info", Info)
} }

View File

@@ -8,36 +8,29 @@ import (
) )
func Info(c *gin.Context) { func Info(c *gin.Context) {
event := new(data.Event) eventData := new(data.Event)
eventIdOrig, ok := c.GetQuery("event_id") eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "undefinded event id"})
"status": "undefinded event id",
})
return return
} }
// Parse event id // Parse event id
eventId, err := uuid.Parse(eventIdOrig) eventId, err := uuid.Parse(eventIdOrig)
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(500, gin.H{"status": "error parsing string to uuid"})
"status": "error parsing string to uuid",
})
return return
} }
err = event.GetEventById(eventId) event, err := eventData.GetEventById(eventId)
if err != nil { if err != nil {
c.JSON(404, gin.H{ c.JSON(404, gin.H{"status": "event id not found"})
"status": "event id not found",
})
return return
} }
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"name": event.Name, "name": event.Name,
"start_time": event.StartTime, "start_time": event.StartTime,
"end_time": event.EndTime, "end_time": event.EndTime,
"joined_users": event.JoinedUsers,
}) })
} }

View File

@@ -8,70 +8,68 @@ import (
) )
func Checkin(c *gin.Context) { func Checkin(c *gin.Context) {
data := new(data.User) data := new(data.Attendance)
userId, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
c.JSON(401, gin.H{ c.JSON(403, gin.H{"status": "userid error"})
"status": "unauthorized",
})
return return
} }
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
}
// Get event id from query // Get event id from query
eventIdOrig, ok := c.GetQuery("event_id") eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "undefinded event id"})
"status": "undefinded event id",
})
return return
} }
// Parse event id to uuid // Parse event id to uuid
eventId, err := uuid.Parse(eventIdOrig) eventId, err := uuid.Parse(eventIdOrig)
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(500, gin.H{"status": "error parsing string to uuid"})
"status": "error parsing string to uuid",
})
return return
} }
data.UserId = userId
data.UserId = userId.(uuid.UUID)
code, err := data.GenCheckinCode(eventId) code, err := data.GenCheckinCode(eventId)
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(500, gin.H{"status": "error generating code"})
"status": "error generating code",
})
return return
} }
c.JSON(200, gin.H{ c.JSON(200, gin.H{"checkin_code": code})
"checkin_code": code,
})
} }
func CheckinSubmit(c *gin.Context) { func CheckinSubmit(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
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"})
}
userData := new(data.User)
userData.GetByUserId(userId)
if userData.PermissionLevel <= 20 {
c.JSON(403, gin.H{"status": "access denied"})
return
}
var req struct { var req struct {
ChekinCode string `json:"checkin_code"` ChekinCode string `json:"checkin_code"`
} }
c.ShouldBindJSON(&req) c.ShouldBindJSON(&req)
data := new(data.User) attendanceData := new(data.Attendance)
userId, err := data.VerifyCheckinCode(req.ChekinCode) err = attendanceData.VerifyCheckinCode(req.ChekinCode)
if err != nil { if err != nil {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "error verify checkin code"})
"status": "error verify checkin code",
})
return return
} }
data.GetByUserId(*userId) c.JSON(200, gin.H{"status": "success"})
if data.PermissionLevel <= 20 {
c.JSON(403, gin.H{
"status": "access denied",
})
}
c.JSON(200, gin.H{
"status": "success",
})
} }

View File

@@ -7,7 +7,7 @@ import (
) )
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.Use(middleware.JWTAuth()) r.Use(middleware.JWTAuth(true))
r.GET("/info", Info) r.GET("/info", Info)
r.GET("/checkin", Checkin) r.GET("/checkin", Checkin)
r.POST("/checkin/submit", CheckinSubmit) r.POST("/checkin/submit", CheckinSubmit)

View File

@@ -2,46 +2,38 @@ package user
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
func Info(c *gin.Context) { func Info(c *gin.Context) {
data := new(data.User) userData := new(data.User)
userId, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
c.JSON(404, gin.H{ c.JSON(403, gin.H{"status": "userid error"})
"status": "user not found", return
}) }
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return return
} }
// Get user from database // Get user from database
err := data.GetByUserId(userId.(uuid.UUID)) user, err := userData.GetByUserId(userId)
if err != nil { if err != nil {
c.JSON(404, gin.H{ c.JSON(404, gin.H{"status": "user not found"})
"status": "user not found",
})
return return
} }
// Set time nil if time is zero
for k, v := range data.Checkin {
if t, ok := v.(time.Time); ok && t.IsZero() {
data.Checkin[k] = nil
}
}
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"user_id": data.UserId, "user_id": user.UserId,
"email": data.Email, "email": user.Email,
"type": data.Type, "nickname": user.Nickname,
"nickname": data.Nickname, "subtitle": user.Subtitle,
"subtitle": data.Subtitle, "avatar": user.Avatar,
"avatar": data.Avatar, "bio": user.Bio,
"checkin": data.Checkin, "permission_level": user.PermissionLevel,
"permission_level": data.PermissionLevel,
}) })
} }

View File

@@ -17,34 +17,26 @@ func List(c *gin.Context) {
} }
offset, ok := c.GetQuery("offset") offset, ok := c.GetQuery("offset")
if !ok { if !ok {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "offset not found"})
"status": "offset not found",
})
return return
} }
// Parse string to int64 // Parse string to int64
limitNum, err := strconv.ParseInt(limit, 10, 64) limitNum, err := strconv.ParseInt(limit, 10, 64)
if err != nil { if err != nil {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "parse string to int error"})
"status": "parse string to int error",
})
return return
} }
offsetNum, err := strconv.ParseInt(offset, 10, 64) offsetNum, err := strconv.ParseInt(offset, 10, 64)
if err != nil { if err != nil {
c.JSON(400, gin.H{ c.JSON(400, gin.H{"status": "parse string to int error"})
"status": "parse string to int error",
})
return return
} }
// Get user list from search engine // Get user list from search engine
list, err := data.FastListUsers(limitNum, offsetNum) list, err := data.FastListUsers(limitNum, offsetNum)
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(500, gin.H{"status": "failed list users from meilisearch"})
"status": "failed list users from meilisearch",
})
} }
c.JSON(200, list) c.JSON(200, list)
} }

View File

@@ -2,42 +2,47 @@ package user
import ( import (
"nixcn-cms/data" "nixcn-cms/data"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
func Query(c *gin.Context) { func Query(c *gin.Context) {
userId, ok := c.Get("user_id") userIdOrig, ok := c.Get("user_id")
if !ok { if !ok {
c.JSON(400, gin.H{"status": "could not found user_id"}) c.JSON(403, gin.H{"status": "userid error"})
return return
} }
eventId, ok := c.GetQuery("event_id") userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{
"status": "failed to parse uuid",
})
}
eventIdOrig, ok := c.GetQuery("event_id")
if !ok { if !ok {
c.JSON(400, gin.H{"status": "could not found event_id"}) c.JSON(400, gin.H{"status": "could not found event_id"})
return return
} }
eventId, err := uuid.Parse(eventIdOrig)
data := new(data.User)
err := data.GetByUserId(userId.(uuid.UUID))
if err != nil { if err != nil {
c.JSON(404, gin.H{"status": "cannot found user"}) c.JSON(400, gin.H{"status": "event_id is not valid"})
return
}
if data.Checkin[eventId] == nil {
c.JSON(404, gin.H{"status": "cannot found user checked in"})
return return
} }
var checkinTime *time.Time attendanceData := new(data.Attendance)
if data.Checkin[eventId].(*time.Time).IsZero() { attendance, err := attendanceData.GetAttendance(userId, eventId)
checkinTime = nil if err != nil {
} else { c.JSON(500, gin.H{"status": "database error"})
checkinTime = data.Checkin[eventId].(*time.Time) return
} else if attendance == nil {
c.JSON(404, gin.H{"status": "event checkin record not found"})
return
} else if attendance.CheckinAt.IsZero() {
c.JSON(200, gin.H{"checkin_at": nil})
return
} }
c.JSON(200, gin.H{
"checkin_time": checkinTime, c.JSON(200, gin.H{"checkin_at": attendance.CheckinAt})
})
} }

View File

@@ -8,27 +8,27 @@ import (
) )
func Update(c *gin.Context) { func Update(c *gin.Context) {
// New user model
user := new(data.User)
userIdOrig, ok := c.Get("user_id")
if !ok {
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"})
}
var ReqInfo data.User var ReqInfo data.User
c.BindJSON(&ReqInfo) c.BindJSON(&ReqInfo)
// New user model
user := new(data.User)
userId, ok := c.Get("user_id")
if !ok {
c.JSON(403, gin.H{
"status": "can not found user id",
})
return
}
// Get user info // Get user info
user.GetByUserId(userId.(uuid.UUID)) user.GetByUserId(userId)
// Reject permission 0 user // Reject permission 0 user
if user.PermissionLevel == 0 { if user.PermissionLevel == 0 {
c.JSON(403, gin.H{ c.JSON(403, gin.H{"status": "premission denied"})
"status": "premission denied",
})
return return
} }
@@ -36,15 +36,9 @@ func Update(c *gin.Context) {
user.Email = ReqInfo.Email user.Email = ReqInfo.Email
user.Nickname = ReqInfo.Nickname user.Nickname = ReqInfo.Nickname
user.Subtitle = ReqInfo.Subtitle user.Subtitle = ReqInfo.Subtitle
// Cant change user type under permission 2
if user.PermissionLevel >= 2 {
user.Type = ReqInfo.Type
}
// Update user info // Update user info
user.UpdateByUserID(userId.(uuid.UUID)) user.UpdateByUserID(userId)
c.JSON(200, gin.H{ c.JSON(200, gin.H{"status": "success"})
"status": "success",
})
} }