Skip to content

Commit fc286e6

Browse files
dengzhaofunclaude
andauthored
feat: 终端玩家 auth 模块 + admin 管理后台 (#41)
* feat(end-user): 终端玩家 auth 模块 + 托管 CRUD API + 测试 apps/server 新增面向游戏玩家的第二个 Better Auth 实例(basepath /api/client/auth),独立于已有的 SaaS 运营者 auth(/api/auth)。租户 既可用 SaaS 托管玩家身份,也可用 Sync API 从自家系统同步用户进来, 两种来源可在同一 org 内混用。 核心设计 - 全新 eu_* 表(eu_user / eu_session / eu_account / eu_verification) 和既有 user/session 等物理分离,`organizationId` FK 指向同一张 organization 表。Email per-tenant 唯一,实现方式是 DB 里存 `{orgId}__{rawEmail}` 命名空间化的 email + 全局 UNIQUE(email), Better Auth 的 email-lookup 路径上加 hooks.before 自动 scope、SDK 层自动脱前缀,外部 API 看到的永远是原始邮箱。 - requireClientUser 改造成双通道收敛:有 Better Auth session 就用 session.user.id(托管玩家,不再需要 HMAC);没有就回退到 cpk+HMAC (同步玩家)。业务 handler 继续读 c.var.endUserId,不感知差异。 - eu_session.organizationId 反规范化 + cpk_.organizationId 强等值校验, 防止跨租户 cookie 盗用。 - eu_user.disabled 软禁用,disable 时级联删除 eu_session 立即生效, session.create.before + requireClientUser 双重拦截后续登录/请求。 CRUD API (/api/end-user,admin auth 保护) - POST /sync — 租户自家用户 upsert,(orgId, externalId) → (orgId, email) 两级匹配,managed 记录只补 externalId 不覆盖密码相关字段 - GET / — 列表(search / origin=managed|synced / disabled / limit+offset) - GET /:id / PATCH /:id / DELETE /:id - POST /:id/disable | /enable | /sign-out-all SDK - packages/sdk-client-ts/src/auth.ts — 封装 sign-up/sign-in/sign-out/ get-session,自动 strip email 前缀 测试 - service.test.ts 21 项 — 覆盖 sync 三种 merge 路径 + identity conflict + CRUD 全套 + setDisabled 级联 + 租户隔离 - routes.test.ts 12 项 — HTTP 边界 + 401/400/404 + 状态码映射 - end-user-auth.test.ts 9 项 — email namespacing、org 注入、跨租户 session guard(403 session_tenant_mismatch)、disabled 拒登 验证 - pnpm test:54 files / 834 tests 全通过 - check-types / lint:0 新错误 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): 后台新增终端玩家管理页(列表 / 详情 / 禁用 / 踢下线 / 删除) 接上一 commit 的服务端 CRUD,给 admin 补一个 /end-user 管理入口。 挂在侧边栏 "Social & Competition" 分组下,图标用 Contact,和 Friend/Guild/Ranking 放一起。 列表页 (/end-user) - 搜索框(匹配 email / name / externalId)+ origin 筛选(全部/托管/同步) + 状态筛选(全部/启用/禁用),映射到服务端 GET /api/end-user 的 query - DataTable 表格:Name(点击进详情) / Email(脱前缀) / External ID / Origin badge(Managed Crown / Synced LinkIcon) / 活跃 session 数 / 状态 badge / 创建时间 - 页头右侧用 PageHeaderActions 显示 "N total" 统计 详情页 (/end-user/$id) - 身份卡(只读):End-user ID / Email / External ID / Active sessions / Created / Updated,右上角显示 Managed/Synced + Enabled/Disabled 双 badge - 编辑表单:Name / Image URL / Email verified switch,PATCH 提交 - Danger zone(带 AlertDialog 二次确认): - Revoke all sessions — 撤销全部会话,不禁用 - Disable user / Enable user — 软禁用,级联踢下线 - Delete permanently — 硬删,警告玩家业务数据(排行榜/背包等以 endUserId 关联的)不会连带删除 i18n - zh + en 各新增 61 个 key,覆盖页面标题、列表过滤、详情字段标签、 三种确认弹窗(title + description)、5 种 toast 消息(成功/失败含 {count}、{message} 插值) - nav_end_user 入口文案 "End Users" / "终端玩家" 数据层 - lib/types/end-user.ts — 手写类型(不走 sdk-admin-ts,和其它模块保持一致) - hooks/use-end-user.ts — 7 个 React Query hook,mutation 成功后 invalidate END_USERS_KEY smoke 验证:登录 admin → 访问 /end-user → 列表渲染 → 点进详情 → 改名字保存 → PATCH /api/end-user/:id 200 → DB 落库确认。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1f1a07 commit fc286e6

30 files changed

Lines changed: 39642 additions & 15 deletions

apps/admin/messages/en.json

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,5 +1415,65 @@
14151415
"rank_match_col_participants": "Players",
14161416
"rank_match_col_settled": "Settled",
14171417
"rank_no_matches": "No matches settled in this season yet.",
1418-
"rank_loading": "Loading…"
1418+
"rank_loading": "Loading…",
1419+
1420+
"nav_end_user": "End Users",
1421+
"end_user_title": "End Users",
1422+
"end_user_subtitle": "Players of your game — managed here or synced in from your own system.",
1423+
"end_user_search_placeholder": "Search email, name, or externalId",
1424+
"end_user_filter_all": "All",
1425+
"end_user_filter_origin_managed": "Managed (password)",
1426+
"end_user_filter_origin_synced": "Synced (external)",
1427+
"end_user_filter_disabled_only": "Disabled only",
1428+
"end_user_filter_enabled_only": "Enabled only",
1429+
"end_user_col_name": "Name",
1430+
"end_user_col_email": "Email",
1431+
"end_user_col_external_id": "External ID",
1432+
"end_user_col_origin": "Origin",
1433+
"end_user_col_sessions": "Sessions",
1434+
"end_user_col_status": "Status",
1435+
"end_user_col_created_at": "Created",
1436+
"end_user_origin_managed": "Managed",
1437+
"end_user_origin_synced": "Synced",
1438+
"end_user_status_enabled": "Enabled",
1439+
"end_user_status_disabled": "Disabled",
1440+
"end_user_status_email_unverified": "Unverified",
1441+
"end_user_empty_title": "No players yet",
1442+
"end_user_empty_description": "Players appear here after they sign up via your game, or after you sync them in.",
1443+
"end_user_total_count": "{count} total",
1444+
"end_user_loading": "Loading…",
1445+
"end_user_detail_back": "Back to list",
1446+
"end_user_detail_section_identity": "Identity",
1447+
"end_user_detail_section_sessions": "Sessions",
1448+
"end_user_detail_section_danger": "Danger zone",
1449+
"end_user_detail_field_id": "End-user ID",
1450+
"end_user_detail_field_email": "Email",
1451+
"end_user_detail_field_external_id": "External ID",
1452+
"end_user_detail_field_name": "Name",
1453+
"end_user_detail_field_image": "Image URL",
1454+
"end_user_detail_field_email_verified": "Email verified",
1455+
"end_user_detail_field_origin": "Origin",
1456+
"end_user_detail_field_status": "Status",
1457+
"end_user_detail_field_created_at": "Created",
1458+
"end_user_detail_field_updated_at": "Updated",
1459+
"end_user_detail_field_session_count": "Active sessions",
1460+
"end_user_detail_action_save": "Save changes",
1461+
"end_user_detail_action_sign_out_all": "Revoke all sessions",
1462+
"end_user_detail_action_disable": "Disable user",
1463+
"end_user_detail_action_enable": "Enable user",
1464+
"end_user_detail_action_delete": "Delete permanently",
1465+
"end_user_detail_sign_out_all_confirm_title": "Revoke all active sessions?",
1466+
"end_user_detail_sign_out_all_confirm_description": "The player will be forced to sign in again on every device.",
1467+
"end_user_detail_disable_confirm_title": "Disable this player?",
1468+
"end_user_detail_disable_confirm_description": "They will be signed out everywhere immediately, and blocked from signing in until you re-enable them.",
1469+
"end_user_detail_delete_confirm_title": "Delete this player permanently?",
1470+
"end_user_detail_delete_confirm_description": "This removes the account, all sessions, and the password. Business data keyed by endUserId (leaderboards, inventory) is NOT deleted. This cannot be undone.",
1471+
"end_user_detail_toast_saved": "Changes saved.",
1472+
"end_user_detail_toast_disabled": "Player disabled and all sessions revoked.",
1473+
"end_user_detail_toast_enabled": "Player enabled.",
1474+
"end_user_detail_toast_signed_out": "Revoked {count} active sessions.",
1475+
"end_user_detail_toast_deleted": "Player deleted.",
1476+
"end_user_detail_toast_save_failed": "Save failed: {message}",
1477+
"end_user_detail_not_found_title": "Player not found",
1478+
"end_user_detail_not_found_description": "This player may have been deleted or belongs to a different organization."
14191479
}

apps/admin/messages/zh.json

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,5 +1415,65 @@
14151415
"rank_match_col_participants": "参赛者",
14161416
"rank_match_col_settled": "结算时间",
14171417
"rank_no_matches": "当前赛季暂无对局。",
1418-
"rank_loading": "加载中…"
1418+
"rank_loading": "加载中…",
1419+
1420+
"nav_end_user": "终端玩家",
1421+
"end_user_title": "终端玩家",
1422+
"end_user_subtitle": "你游戏的玩家 —— 可托管注册或从你自己的账号体系同步而来。",
1423+
"end_user_search_placeholder": "搜索邮箱、昵称或外部 ID",
1424+
"end_user_filter_all": "全部",
1425+
"end_user_filter_origin_managed": "托管(密码登录)",
1426+
"end_user_filter_origin_synced": "同步(外部账号)",
1427+
"end_user_filter_disabled_only": "只看禁用",
1428+
"end_user_filter_enabled_only": "只看启用",
1429+
"end_user_col_name": "昵称",
1430+
"end_user_col_email": "邮箱",
1431+
"end_user_col_external_id": "外部 ID",
1432+
"end_user_col_origin": "来源",
1433+
"end_user_col_sessions": "会话",
1434+
"end_user_col_status": "状态",
1435+
"end_user_col_created_at": "创建时间",
1436+
"end_user_origin_managed": "托管",
1437+
"end_user_origin_synced": "同步",
1438+
"end_user_status_enabled": "正常",
1439+
"end_user_status_disabled": "已禁用",
1440+
"end_user_status_email_unverified": "未验证",
1441+
"end_user_empty_title": "暂无玩家",
1442+
"end_user_empty_description": "玩家在游戏里注册或通过同步接口进来之后,会出现在这里。",
1443+
"end_user_total_count": "共 {count} 人",
1444+
"end_user_loading": "加载中…",
1445+
"end_user_detail_back": "返回列表",
1446+
"end_user_detail_section_identity": "身份信息",
1447+
"end_user_detail_section_sessions": "会话",
1448+
"end_user_detail_section_danger": "高危操作",
1449+
"end_user_detail_field_id": "玩家 ID",
1450+
"end_user_detail_field_email": "邮箱",
1451+
"end_user_detail_field_external_id": "外部 ID",
1452+
"end_user_detail_field_name": "昵称",
1453+
"end_user_detail_field_image": "头像 URL",
1454+
"end_user_detail_field_email_verified": "邮箱已验证",
1455+
"end_user_detail_field_origin": "来源",
1456+
"end_user_detail_field_status": "状态",
1457+
"end_user_detail_field_created_at": "创建时间",
1458+
"end_user_detail_field_updated_at": "更新时间",
1459+
"end_user_detail_field_session_count": "活跃会话数",
1460+
"end_user_detail_action_save": "保存修改",
1461+
"end_user_detail_action_sign_out_all": "踢下线所有会话",
1462+
"end_user_detail_action_disable": "禁用用户",
1463+
"end_user_detail_action_enable": "恢复启用",
1464+
"end_user_detail_action_delete": "彻底删除",
1465+
"end_user_detail_sign_out_all_confirm_title": "踢下线所有活跃会话?",
1466+
"end_user_detail_sign_out_all_confirm_description": "玩家所有设备上的登录态都会立刻失效,需要重新登录。",
1467+
"end_user_detail_disable_confirm_title": "禁用这个玩家?",
1468+
"end_user_detail_disable_confirm_description": "立即踢下线所有会话,且禁止重新登录,直到你恢复启用。",
1469+
"end_user_detail_delete_confirm_title": "彻底删除这个玩家?",
1470+
"end_user_detail_delete_confirm_description": "将删除账号、所有会话和密码。业务数据(天梯、背包等按 endUserId 关联的)不会被删除。此操作无法撤销。",
1471+
"end_user_detail_toast_saved": "修改已保存。",
1472+
"end_user_detail_toast_disabled": "玩家已禁用,所有会话已撤销。",
1473+
"end_user_detail_toast_enabled": "玩家已恢复启用。",
1474+
"end_user_detail_toast_signed_out": "已撤销 {count} 个活跃会话。",
1475+
"end_user_detail_toast_deleted": "玩家已删除。",
1476+
"end_user_detail_toast_save_failed": "保存失败:{message}",
1477+
"end_user_detail_not_found_title": "找不到该玩家",
1478+
"end_user_detail_not_found_description": "这个玩家可能已被删除,或属于其他组织。"
14191479
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CalendarCheck,
77
ChevronRight,
88
Coins,
9+
Contact,
910
Dices,
1011
FolderOpen,
1112
GalleryHorizontal,
@@ -85,6 +86,7 @@ type NavItem = {
8586
| "/team"
8687
| "/leaderboard"
8788
| "/rank"
89+
| "/end-user"
8890
| "/api-keys"
8991
icon: LucideIcon
9092
}
@@ -152,6 +154,7 @@ function getNavGroups(): NavGroup[] {
152154
{ title: m.nav_team, to: "/team", icon: Swords },
153155
{ title: m.nav_leaderboard, to: "/leaderboard", icon: Trophy },
154156
{ title: m.nav_rank, to: "/rank", icon: Medal },
157+
{ title: m.nav_end_user, to: "/end-user", icon: Contact },
155158
],
156159
},
157160
{
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Table for the end-user list page. Wraps the shared `DataTable` with
3+
* the column definitions for a player row.
4+
*/
5+
import type { ColumnDef } from "@tanstack/react-table"
6+
import { Link } from "@tanstack/react-router"
7+
import { Ban, CheckCircle2, Crown, LinkIcon } from "lucide-react"
8+
9+
import { Badge } from "#/components/ui/badge"
10+
import { DataTable } from "#/components/data-table/DataTable"
11+
import type { EndUser } from "#/lib/types/end-user"
12+
import * as m from "#/paraglide/messages.js"
13+
14+
function formatDate(iso: string): string {
15+
const d = new Date(iso)
16+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
17+
}
18+
19+
const columns: ColumnDef<EndUser>[] = [
20+
{
21+
accessorKey: "name",
22+
header: () => m.end_user_col_name(),
23+
cell: ({ row }) => (
24+
<Link
25+
to="/end-user/$id"
26+
params={{ id: row.original.id }}
27+
className="font-medium hover:underline"
28+
>
29+
{row.original.name}
30+
</Link>
31+
),
32+
},
33+
{
34+
accessorKey: "email",
35+
header: () => m.end_user_col_email(),
36+
cell: ({ row }) => (
37+
<span className="text-sm text-muted-foreground">{row.original.email}</span>
38+
),
39+
},
40+
{
41+
accessorKey: "externalId",
42+
header: () => m.end_user_col_external_id(),
43+
cell: ({ row }) =>
44+
row.original.externalId ? (
45+
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">
46+
{row.original.externalId}
47+
</code>
48+
) : (
49+
<span className="text-xs text-muted-foreground"></span>
50+
),
51+
},
52+
{
53+
accessorKey: "origin",
54+
header: () => m.end_user_col_origin(),
55+
cell: ({ row }) =>
56+
row.original.origin === "managed" ? (
57+
<Badge variant="secondary" className="gap-1">
58+
<Crown className="size-3" />
59+
{m.end_user_origin_managed()}
60+
</Badge>
61+
) : (
62+
<Badge variant="outline" className="gap-1">
63+
<LinkIcon className="size-3" />
64+
{m.end_user_origin_synced()}
65+
</Badge>
66+
),
67+
},
68+
{
69+
accessorKey: "sessionCount",
70+
header: () => m.end_user_col_sessions(),
71+
cell: ({ row }) => (
72+
<span className="tabular-nums text-sm">{row.original.sessionCount}</span>
73+
),
74+
},
75+
{
76+
accessorKey: "disabled",
77+
header: () => m.end_user_col_status(),
78+
cell: ({ row }) =>
79+
row.original.disabled ? (
80+
<Badge variant="destructive" className="gap-1">
81+
<Ban className="size-3" />
82+
{m.end_user_status_disabled()}
83+
</Badge>
84+
) : (
85+
<Badge variant="secondary" className="gap-1">
86+
<CheckCircle2 className="size-3" />
87+
{m.end_user_status_enabled()}
88+
</Badge>
89+
),
90+
},
91+
{
92+
accessorKey: "createdAt",
93+
header: () => m.end_user_col_created_at(),
94+
cell: ({ row }) => (
95+
<span className="text-sm text-muted-foreground tabular-nums">
96+
{formatDate(row.original.createdAt)}
97+
</span>
98+
),
99+
},
100+
]
101+
102+
interface Props {
103+
data: EndUser[]
104+
isLoading?: boolean
105+
toolbar?: React.ReactNode
106+
}
107+
108+
export function EndUserTable({ data, isLoading, toolbar }: Props) {
109+
// `showSearch={false}` — the list page renders its own search box
110+
// that drives server-side filters; we don't want DataTable's
111+
// client-side filter shadowing it.
112+
return (
113+
<DataTable
114+
columns={columns}
115+
data={data}
116+
isLoading={isLoading}
117+
toolbar={toolbar}
118+
showSearch={false}
119+
getRowId={(r) => r.id}
120+
/>
121+
)
122+
}

0 commit comments

Comments
 (0)