create-fullstack-scaffold 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli/index.js +65 -2
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -10
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/server/db/init.ts +0 -52
  7. package/template/src/server/db/schema/index.ts +3 -0
  8. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  9. package/template/src/server/db/schema/tenant-members.ts +38 -0
  10. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  11. package/template/src/server/db/schema/todos.ts +4 -0
  12. package/template/src/server/db/test-setup.ts +128 -0
  13. package/template/src/server/entries/node.ts +13 -0
  14. package/template/src/server/index.ts +12 -0
  15. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  16. package/template/src/server/middleware/auth.ts +17 -19
  17. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  18. package/template/src/server/module-auth/module.ts +2 -1
  19. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  20. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  21. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  22. package/template/src/server/module-tenant/module.ts +1 -1
  23. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  24. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  25. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  26. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  27. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  28. package/template/src/server/utils/id-helpers.ts +13 -0
  29. package/template/src/shared/modules/index.ts +29 -6
  30. package/template/src/shared/modules/tenant/index.ts +30 -0
  31. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  32. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  33. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  34. package/template/src/shared/modules/todos/schemas.ts +1 -0
  35. package/template/src/shared/schemas/index.ts +28 -0
  36. package/template/src/tenant/App.tsx +30 -23
  37. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  38. package/template/src/tenant/layouts/Header.tsx +6 -2
  39. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  40. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  41. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  42. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  43. package/template/src/tenant/services/tenantApi.ts +61 -0
  44. package/template/src/tenant/stores/tenantStore.ts +172 -49
  45. package/template/src/test/setup-db-path.ts +20 -0
  46. package/template/vitest.config.ts +3 -1
@@ -0,0 +1,97 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate, useParams } from 'react-router-dom'
3
+ import { Card, Button, Typography, Spin, App } from 'antd'
4
+ import { api, getToken } from '../services/tenantApi'
5
+ import { useTenantStore } from '../stores/tenantStore'
6
+ import type { PublicInvitation } from '@shared/schemas'
7
+
8
+ /**
9
+ * 邀请落地页(公开路由 /tenant/invite/:token)。
10
+ * 展示脱敏邀请详情;未登录先跳登录(回跳本页),登录后一键接受入组。
11
+ */
12
+ export const InviteAcceptPage: React.FC = () => {
13
+ const { token = '' } = useParams()
14
+ const navigate = useNavigate()
15
+ const { message } = App.useApp()
16
+ const [detail, setDetail] = useState<PublicInvitation | null>(null)
17
+ const [loading, setLoading] = useState(true)
18
+ const [accepting, setAccepting] = useState(false)
19
+
20
+ useEffect(() => {
21
+ let cancelled = false
22
+ ;(async () => {
23
+ const res = await api<PublicInvitation>(`/tenants/invitations/${token}`)
24
+ if (!cancelled) {
25
+ setDetail(res.success && res.data ? res.data : null)
26
+ setLoading(false)
27
+ }
28
+ })()
29
+ return () => {
30
+ cancelled = true
31
+ }
32
+ }, [token])
33
+
34
+ const restoreFromToken = useTenantStore(state => state.restoreFromToken)
35
+
36
+ const handleAccept = async () => {
37
+ if (!getToken()) {
38
+ navigate('/login', { state: { from: `/invite/${token}` } })
39
+ return
40
+ }
41
+ setAccepting(true)
42
+ const res = await api(`/tenants/invitations/${token}/accept`, { method: 'POST' })
43
+ setAccepting(false)
44
+ if (res.success) {
45
+ message.success('Invitation accepted — welcome!')
46
+ // 入组成功即有租户——恢复上下文(写 slug)再进 dashboard,
47
+ // 否则 TenantGuard 因 currentTenant 为空永久 Spin(白屏)
48
+ await restoreFromToken()
49
+ navigate('/dashboard', { replace: true })
50
+ } else {
51
+ message.error('Invitation is invalid, used or expired')
52
+ }
53
+ }
54
+
55
+ if (loading) {
56
+ return (
57
+ <div className="min-h-screen flex items-center justify-center">
58
+ <Spin size="large" />
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <div
65
+ className="min-h-screen flex items-center justify-center bg-gray-100"
66
+ data-testid="tenant-invite"
67
+ >
68
+ <Card
69
+ className="w-96 shadow-lg text-center"
70
+ title={<Typography.Title level={4}>Tenant Invitation</Typography.Title>}
71
+ >
72
+ {detail ? (
73
+ <>
74
+ <Typography.Paragraph>
75
+ You are invited to join <strong>{detail.tenantName}</strong> as{' '}
76
+ <strong>{detail.roleLabel}</strong>
77
+ </Typography.Paragraph>
78
+ <Typography.Paragraph type="secondary">
79
+ Invited email: {detail.email}
80
+ </Typography.Paragraph>
81
+ {detail.status === 'pending' ? (
82
+ <Button type="primary" block loading={accepting} onClick={handleAccept}>
83
+ Accept invitation
84
+ </Button>
85
+ ) : (
86
+ <Typography.Paragraph type="warning">
87
+ This invitation is {detail.status}.
88
+ </Typography.Paragraph>
89
+ )}
90
+ </>
91
+ ) : (
92
+ <Typography.Paragraph type="danger">Invitation not found.</Typography.Paragraph>
93
+ )}
94
+ </Card>
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,82 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate, useLocation } from 'react-router-dom'
3
+ import { Card, Form, Input, Button, Typography, App } from 'antd'
4
+ import { LockOutlined, UserOutlined } from '@ant-design/icons'
5
+ import { useTenantStore } from '../stores/tenantStore'
6
+
7
+ /**
8
+ * 租户控制台登录——平台账号认证 + /tenants/mine 选定成员租户。
9
+ * 无租户归属的账号会被明确拒绝(多租户语义的入口门槛)。
10
+ */
11
+ export const LoginPage: React.FC = () => {
12
+ const navigate = useNavigate()
13
+ const location = useLocation()
14
+ const login = useTenantStore(state => state.login)
15
+ const loading = useTenantStore(state => state.loading)
16
+ const [error, setError] = useState<string | null>(null)
17
+ const { message } = App.useApp()
18
+
19
+ const onFinish = async (values: { account: string; password: string }) => {
20
+ setError(null)
21
+ const result = await login(values.account, values.password)
22
+ if (!result.ok) {
23
+ setError(result.error ?? 'Login failed')
24
+ return
25
+ }
26
+ if (result.hasTenant) {
27
+ message.success('Welcome back')
28
+ navigate('/dashboard', { replace: true })
29
+ return
30
+ }
31
+ // 认证成功但尚无租户:受邀新用户回跳邀请落地页完成入组
32
+ const from = (location.state as { from?: string } | null)?.from
33
+ if (from && from.startsWith('/invite/')) {
34
+ navigate(from, { replace: true })
35
+ } else {
36
+ setError(
37
+ 'Signed in, but this account belongs to no tenant yet. Open your invitation link to join one.'
38
+ )
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className="min-h-screen flex items-center justify-center bg-gray-100"
45
+ data-testid="tenant-login"
46
+ >
47
+ <Card
48
+ className="w-96 shadow-lg"
49
+ title={<Typography.Title level={4}>Tenant Console</Typography.Title>}
50
+ >
51
+ <Typography.Paragraph type="secondary">
52
+ Sign in with your platform account. Accounts belonging to a tenant will enter that
53
+ tenant's console.
54
+ </Typography.Paragraph>
55
+ <Form layout="vertical" onFinish={onFinish} data-testid="tenant-login-form">
56
+ <Form.Item
57
+ name="account"
58
+ label="Account"
59
+ rules={[{ required: true, message: 'Please input your account' }]}
60
+ >
61
+ <Input prefix={<UserOutlined />} placeholder="username / email" />
62
+ </Form.Item>
63
+ <Form.Item
64
+ name="password"
65
+ label="Password"
66
+ rules={[{ required: true, message: 'Please input your password' }]}
67
+ >
68
+ <Input.Password prefix={<LockOutlined />} placeholder="password" />
69
+ </Form.Item>
70
+ {error && (
71
+ <Typography.Paragraph type="danger" data-testid="tenant-login-error">
72
+ {error}
73
+ </Typography.Paragraph>
74
+ )}
75
+ <Button type="primary" htmlType="submit" block loading={loading}>
76
+ Sign in
77
+ </Button>
78
+ </Form>
79
+ </Card>
80
+ </div>
81
+ )
82
+ }
@@ -1,8 +1,12 @@
1
1
  import { useEffect } from 'react'
2
- import { Card, Descriptions, Button, message } from 'antd'
3
- import { CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
2
+ import { Card, Descriptions, Progress, Button, Typography, Tooltip } from 'antd'
4
3
  import { useTenantStore } from '../stores/tenantStore'
5
4
 
5
+ /**
6
+ * 订阅/配额页——全部真实数据:plan/maxUsers 来自租户记录,
7
+ * 成员用量来自 members 接口;配额在邀请与接受两处服务端真实拦截。
8
+ * 套餐变更走平台管理员( tenants 属平台管理域),此处按钮以提示代替假动作。
9
+ */
6
10
  export const SubscriptionPage: React.FC = () => {
7
11
  const { subscription, loading, fetchSubscription } = useTenantStore()
8
12
 
@@ -10,72 +14,45 @@ export const SubscriptionPage: React.FC = () => {
10
14
  fetchSubscription()
11
15
  }, [fetchSubscription])
12
16
 
13
- const getStatusIcon = (status: string) => {
14
- switch (status) {
15
- case 'active':
16
- return <CheckCircleOutlined style={{ color: '#52c41a' }} />
17
- case 'pending':
18
- return <ClockCircleOutlined style={{ color: '#faad14' }} />
19
- case 'cancelled':
20
- return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
21
- default:
22
- return null
23
- }
24
- }
25
-
26
- const handleUpgrade = () => {
27
- message.success('Upgrade feature coming soon!')
28
- }
29
-
30
- const handleCancel = () => {
31
- message.success('Cancel subscription feature coming soon!')
32
- }
33
-
34
- if (loading) {
17
+ if (loading && !subscription) {
35
18
  return <div>Loading subscription...</div>
36
19
  }
37
20
 
38
- const subData = subscription as {
39
- plan?: string
40
- status?: string
41
- startDate?: string
42
- endDate?: string
43
- usersLimit?: number
44
- storageLimit?: string
45
- features?: string[]
21
+ if (!subscription) {
22
+ return <div>No subscription data</div>
46
23
  }
47
24
 
25
+ const { plan, maxUsers, currentUsers } = subscription
26
+ const percent = maxUsers > 0 ? Math.round((currentUsers / maxUsers) * 100) : 0
27
+
48
28
  return (
49
29
  <div data-testid="tenant-subscription">
50
30
  <h1 className="text-2xl font-bold mb-6">Subscription</h1>
51
31
  <Card>
52
- <Descriptions title={subData?.plan || 'No Plan'} bordered>
53
- <Descriptions.Item label="Status">
54
- {subData?.status ? (
55
- <span>
56
- {getStatusIcon(subData.status)} {subData.status}
57
- </span>
58
- ) : (
59
- 'No subscription'
60
- )}
32
+ <Descriptions title={plan.toUpperCase()} bordered>
33
+ <Descriptions.Item label="Plan">{plan}</Descriptions.Item>
34
+ <Descriptions.Item label="Members">
35
+ {currentUsers} / {maxUsers}
61
36
  </Descriptions.Item>
62
- <Descriptions.Item label="Start Date">{subData?.startDate || '-'}</Descriptions.Item>
63
- <Descriptions.Item label="End Date">{subData?.endDate || '-'}</Descriptions.Item>
64
- <Descriptions.Item label="Users Limit">{subData?.usersLimit || '-'}</Descriptions.Item>
65
- <Descriptions.Item label="Storage Limit">
66
- {subData?.storageLimit || '-'}
67
- </Descriptions.Item>
68
- <Descriptions.Item label="Features">
69
- {subData?.features?.join(', ') || 'None'}
37
+ <Descriptions.Item label="Custom role limit">
38
+ {{ free: 3, starter: 5, pro: 10, enterprise: 'Unlimited' }[plan] ?? '-'}
70
39
  </Descriptions.Item>
71
40
  </Descriptions>
41
+ <div className="mt-6">
42
+ <Typography.Paragraph strong>Member quota usage</Typography.Paragraph>
43
+ <Progress percent={percent} status={percent >= 100 ? 'exception' : 'normal'} />
44
+ {percent >= 100 && (
45
+ <Typography.Paragraph type="danger">
46
+ Quota is full — new invitations will be rejected until the plan is upgraded.
47
+ </Typography.Paragraph>
48
+ )}
49
+ </div>
72
50
  <div className="mt-6 flex gap-4">
73
- <Button type="primary" onClick={handleUpgrade}>
74
- Upgrade Plan
75
- </Button>
76
- <Button danger onClick={handleCancel}>
77
- Cancel Subscription
78
- </Button>
51
+ <Tooltip title="Plan changes are managed by the platform administrator">
52
+ <Button type="primary" disabled>
53
+ Upgrade Plan
54
+ </Button>
55
+ </Tooltip>
79
56
  </div>
80
57
  </Card>
81
58
  </div>
@@ -1,109 +1,123 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { Table, Button, Space, Modal, Form, Input, message } from 'antd'
2
+ import { Table, Button, Space, Modal, Form, Input, Select, Typography, App } from 'antd'
3
3
  import type { ColumnsType } from 'antd/es/table'
4
- import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
4
+ import { EditOutlined, DeleteOutlined, PlusOutlined, UserAddOutlined } from '@ant-design/icons'
5
5
  import { useTenantStore } from '../stores/tenantStore'
6
+ import type { TenantMember } from '@shared/schemas'
6
7
 
8
+ /**
9
+ * 租户成员管理:列表(含角色)/邀请新成员(邮件+角色)/改角色/移除。
10
+ * 后端在邀请与接受时执行套餐成员数配额(P4)。
11
+ */
7
12
  export const UsersPage: React.FC = () => {
8
- const { users, loading, fetchUsers, createUser, updateUser, deleteUser } = useTenantStore()
9
- const [isModalOpen, setIsModalOpen] = useState(false)
10
- const [editingUser, setEditingUser] = useState<unknown | null>(null)
11
- const [form] = Form.useForm()
13
+ const { users, roles, loading, fetchUsers, fetchRoles, inviteUser, updateUser, deleteUser } =
14
+ useTenantStore()
15
+ const { message } = App.useApp()
16
+ const [inviteOpen, setInviteOpen] = useState(false)
17
+ const [roleTarget, setRoleTarget] = useState<TenantMember | null>(null)
18
+ const [inviteForm] = Form.useForm()
19
+ const [roleForm] = Form.useForm()
12
20
 
13
21
  useEffect(() => {
14
22
  fetchUsers()
15
- }, [fetchUsers])
23
+ fetchRoles()
24
+ }, [fetchUsers, fetchRoles])
16
25
 
17
- const handleAdd = () => {
18
- setEditingUser(null)
19
- form.resetFields()
20
- setIsModalOpen(true)
26
+ const handleInvite = async () => {
27
+ try {
28
+ const values = await inviteForm.validateFields()
29
+ const ok = await inviteUser(values.email, values.roleId)
30
+ if (ok) {
31
+ message.success('Invitation created — the invitee will receive a join link')
32
+ setInviteOpen(false)
33
+ inviteForm.resetFields()
34
+ fetchUsers()
35
+ } else {
36
+ message.error('Failed to create invitation')
37
+ }
38
+ } catch {
39
+ // validation error
40
+ }
21
41
  }
22
42
 
23
- const handleEdit = (user: unknown) => {
24
- setEditingUser(user)
25
- form.setFieldsValue(user)
26
- setIsModalOpen(true)
43
+ const handleRoleChange = async () => {
44
+ if (!roleTarget) return
45
+ try {
46
+ const values = await roleForm.validateFields()
47
+ const ok = await updateUser(roleTarget.id, { roleId: values.roleId })
48
+ if (ok) {
49
+ message.success('Member role updated')
50
+ setRoleTarget(null)
51
+ fetchUsers()
52
+ } else {
53
+ message.error('Failed to update role')
54
+ }
55
+ } catch {
56
+ // validation error
57
+ }
27
58
  }
28
59
 
29
- const handleDelete = async (userId: number) => {
60
+ const handleRemove = (member: TenantMember) => {
30
61
  Modal.confirm({
31
- title: 'Confirm Delete',
32
- content: 'Are you sure you want to delete this user?',
33
- okText: 'Yes',
34
- cancelText: 'No',
62
+ title: 'Remove member',
63
+ content: `Remove ${member.userId} from this tenant? They will lose access immediately.`,
64
+ okText: 'Remove',
65
+ okButtonProps: { danger: true },
66
+ cancelText: 'Cancel',
35
67
  onOk: async () => {
36
- const success = await deleteUser(userId)
37
- if (success) {
38
- message.success('User deleted successfully')
68
+ const ok = await deleteUser(member.id)
69
+ if (ok) {
70
+ message.success('Member removed')
39
71
  fetchUsers()
72
+ } else {
73
+ message.error('Failed to remove member')
40
74
  }
41
75
  },
42
76
  })
43
77
  }
44
78
 
45
- const handleSubmit = async () => {
46
- try {
47
- const values = await form.validateFields()
48
-
49
- if (editingUser) {
50
- const userId = (editingUser as { id: number }).id
51
- const success = await updateUser(userId, values)
52
- if (success) {
53
- message.success('User updated successfully')
54
- fetchUsers()
55
- }
56
- } else {
57
- const success = await createUser(values)
58
- if (success) {
59
- message.success('User created successfully')
60
- fetchUsers()
61
- }
62
- }
63
-
64
- setIsModalOpen(false)
65
- form.resetFields()
66
- } catch (error) {
67
- console.error('Validation failed:', error)
68
- }
69
- }
70
-
71
- const columns: ColumnsType<unknown> = [
79
+ const columns: ColumnsType<TenantMember> = [
80
+ { title: 'Account', dataIndex: 'username', key: 'username' },
72
81
  {
73
- title: 'ID',
74
- dataIndex: 'id',
75
- key: 'id',
76
- },
77
- {
78
- title: 'Username',
79
- dataIndex: 'username',
80
- key: 'username',
82
+ title: 'Role',
83
+ key: 'role',
84
+ render: (_, record) => (
85
+ <Typography.Text code>{record.role?.label ?? record.roleId}</Typography.Text>
86
+ ),
81
87
  },
82
88
  {
83
- title: 'Email',
84
- dataIndex: 'email',
85
- key: 'email',
89
+ title: 'Status',
90
+ dataIndex: 'status',
91
+ key: 'status',
86
92
  },
87
93
  {
88
- title: 'Role',
89
- dataIndex: 'role',
90
- key: 'role',
94
+ title: 'Joined',
95
+ dataIndex: 'joinedAt',
96
+ key: 'joinedAt',
97
+ render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'),
91
98
  },
92
99
  {
93
100
  title: 'Actions',
94
101
  key: 'actions',
95
102
  render: (_, record) => (
96
103
  <Space size="middle">
97
- <Button icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>
98
- Edit
104
+ <Button
105
+ icon={<EditOutlined />}
106
+ size="small"
107
+ onClick={() => {
108
+ setRoleTarget(record)
109
+ roleForm.setFieldsValue({ roleId: record.roleId })
110
+ }}
111
+ >
112
+ Role
99
113
  </Button>
100
114
  <Button
101
115
  icon={<DeleteOutlined />}
102
116
  size="small"
103
117
  danger
104
- onClick={() => handleDelete(Number((record as { id: unknown }).id))}
118
+ onClick={() => handleRemove(record)}
105
119
  >
106
- Delete
120
+ Remove
107
121
  </Button>
108
122
  </Space>
109
123
  ),
@@ -112,32 +126,64 @@ export const UsersPage: React.FC = () => {
112
126
 
113
127
  return (
114
128
  <div data-testid="tenant-users">
115
- <div className="flex justify-between items-center mb-6">
116
- <h1 className="text-2xl font-bold">Users</h1>
117
- <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
118
- Add User
129
+ <div className="flex justify-between items-center mb-4">
130
+ <Typography.Title level={5} className="!mb-0">
131
+ Members
132
+ </Typography.Title>
133
+ <Button type="primary" icon={<PlusOutlined />} onClick={() => setInviteOpen(true)}>
134
+ Invite member
119
135
  </Button>
120
136
  </div>
121
- <Table columns={columns} dataSource={users as unknown[]} loading={loading} rowKey="id" />
137
+ <Table columns={columns} dataSource={users} loading={loading} rowKey="id" />
138
+
122
139
  <Modal
123
- title={editingUser ? 'Edit User' : 'Add User'}
124
- open={isModalOpen}
125
- onOk={handleSubmit}
126
- onCancel={() => setIsModalOpen(false)}
127
- destroyOnClose
140
+ title={
141
+ <span>
142
+ <UserAddOutlined /> Invite member
143
+ </span>
144
+ }
145
+ open={inviteOpen}
146
+ onOk={handleInvite}
147
+ onCancel={() => setInviteOpen(false)}
148
+ okText="Send invitation"
128
149
  >
129
- <Form form={form} layout="vertical">
130
- <Form.Item name="username" label="Username" rules={[{ required: true }]}>
131
- <Input />
132
- </Form.Item>
133
- <Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
134
- <Input />
150
+ <Typography.Paragraph type="secondary">
151
+ An invitation link (valid for 7 days) will be generated for the invitee.
152
+ </Typography.Paragraph>
153
+ <Form form={inviteForm} layout="vertical">
154
+ <Form.Item
155
+ name="email"
156
+ label="Email"
157
+ rules={[
158
+ { required: true, message: 'Please input email' },
159
+ { type: 'email', message: 'Invalid email' },
160
+ ]}
161
+ >
162
+ <Input placeholder="teammate@example.com" />
135
163
  </Form.Item>
136
- <Form.Item name="password" label="Password" rules={[{ required: !editingUser }]}>
137
- <Input.Password />
164
+ <Form.Item
165
+ name="roleId"
166
+ label="Role"
167
+ rules={[{ required: true, message: 'Pick a role' }]}
168
+ >
169
+ <Select
170
+ placeholder="Select role"
171
+ options={roles.map(r => ({ value: r.id, label: r.label }))}
172
+ />
138
173
  </Form.Item>
139
- <Form.Item name="role" label="Role" rules={[{ required: true }]}>
140
- <Input />
174
+ </Form>
175
+ </Modal>
176
+
177
+ <Modal
178
+ title="Change member role"
179
+ open={!!roleTarget}
180
+ onOk={handleRoleChange}
181
+ onCancel={() => setRoleTarget(null)}
182
+ okText="Save"
183
+ >
184
+ <Form form={roleForm} layout="vertical">
185
+ <Form.Item name="roleId" label="Role" rules={[{ required: true }]}>
186
+ <Select options={roles.map(r => ({ value: r.id, label: r.label }))} />
141
187
  </Form.Item>
142
188
  </Form>
143
189
  </Modal>
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 租户控制台 API 层——统一注入认证与租户上下文。
3
+ *
4
+ * token:登录(平台认证 /api/auth/login)后存 localStorage('tenant-token')
5
+ * slug:登录时从 /api/tenants/mine 选定,存 localStorage('current-tenant-slug')
6
+ */
7
+
8
+ const TOKEN_KEY = 'tenant-token'
9
+ const SLUG_KEY = 'current-tenant-slug'
10
+
11
+ export function getToken(): string | null {
12
+ return localStorage.getItem(TOKEN_KEY)
13
+ }
14
+
15
+ export function setToken(token: string | null): void {
16
+ if (token) localStorage.setItem(TOKEN_KEY, token)
17
+ else localStorage.removeItem(TOKEN_KEY)
18
+ }
19
+
20
+ export function getSlug(): string | null {
21
+ return localStorage.getItem(SLUG_KEY)
22
+ }
23
+
24
+ export function setSlug(slug: string | null): void {
25
+ if (slug) localStorage.setItem(SLUG_KEY, slug)
26
+ else localStorage.removeItem(SLUG_KEY)
27
+ }
28
+
29
+ function headers(json = true): Record<string, string> {
30
+ const h: Record<string, string> = {}
31
+ if (json) h['Content-Type'] = 'application/json'
32
+ const token = getToken()
33
+ if (token) h['Authorization'] = `Bearer ${token}`
34
+ const slug = getSlug()
35
+ if (slug) h['X-Tenant-Slug'] = slug
36
+ return h
37
+ }
38
+
39
+ export interface ApiResponse<T> {
40
+ success: boolean
41
+ data?: T
42
+ error?: string
43
+ status?: number
44
+ }
45
+
46
+ export async function api<T>(
47
+ path: string,
48
+ options: { method?: string; body?: unknown } = {}
49
+ ): Promise<ApiResponse<T>> {
50
+ try {
51
+ const res = await fetch(`/api${path}`, {
52
+ method: options.method ?? 'GET',
53
+ headers: headers(),
54
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
55
+ })
56
+ const json = (await res.json()) as ApiResponse<T>
57
+ return { ...json, status: res.status }
58
+ } catch (e) {
59
+ return { success: false, error: e instanceof Error ? e.message : 'Network error' }
60
+ }
61
+ }