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.
- package/dist/cli/index.js +65 -2
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -10
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +13 -0
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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={
|
|
53
|
-
<Descriptions.Item label="
|
|
54
|
-
|
|
55
|
-
|
|
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="
|
|
63
|
-
|
|
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
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
2
|
+
import { Table, Button, Space, Modal, Form, Input, Select, Typography, App } from 'antd'
|
|
3
3
|
import type { ColumnsType } from 'antd/es/table'
|
|
4
|
-
import {
|
|
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,
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const [
|
|
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
|
-
|
|
23
|
+
fetchRoles()
|
|
24
|
+
}, [fetchUsers, fetchRoles])
|
|
16
25
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
60
|
+
const handleRemove = (member: TenantMember) => {
|
|
30
61
|
Modal.confirm({
|
|
31
|
-
title: '
|
|
32
|
-
content:
|
|
33
|
-
okText: '
|
|
34
|
-
|
|
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
|
|
37
|
-
if (
|
|
38
|
-
message.success('
|
|
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
|
|
46
|
-
|
|
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: '
|
|
74
|
-
|
|
75
|
-
|
|
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: '
|
|
84
|
-
dataIndex: '
|
|
85
|
-
key: '
|
|
89
|
+
title: 'Status',
|
|
90
|
+
dataIndex: 'status',
|
|
91
|
+
key: 'status',
|
|
86
92
|
},
|
|
87
93
|
{
|
|
88
|
-
title: '
|
|
89
|
-
dataIndex: '
|
|
90
|
-
key: '
|
|
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
|
|
98
|
-
|
|
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={() =>
|
|
118
|
+
onClick={() => handleRemove(record)}
|
|
105
119
|
>
|
|
106
|
-
|
|
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-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
137
|
+
<Table columns={columns} dataSource={users} loading={loading} rowKey="id" />
|
|
138
|
+
|
|
122
139
|
<Modal
|
|
123
|
-
title={
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
<Form.Item
|
|
134
|
-
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
+
}
|