@varshylinc/team-management 0.1.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/.eslintrc.cjs +18 -0
- package/CHANGELOG.md +159 -0
- package/LICENSE +6 -0
- package/README.md +97 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server/crypto.d.ts +6 -0
- package/dist/server/crypto.d.ts.map +1 -0
- package/dist/server/crypto.js +42 -0
- package/dist/server/crypto.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +114 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware/require-membership.d.ts +10 -0
- package/dist/server/middleware/require-membership.d.ts.map +1 -0
- package/dist/server/middleware/require-membership.js +33 -0
- package/dist/server/middleware/require-membership.js.map +1 -0
- package/dist/server/middleware/require-role.d.ts +4 -0
- package/dist/server/middleware/require-role.d.ts.map +1 -0
- package/dist/server/middleware/require-role.js +16 -0
- package/dist/server/middleware/require-role.js.map +1 -0
- package/dist/server/middleware/require-super-admin.d.ts +5 -0
- package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
- package/dist/server/middleware/require-super-admin.js +27 -0
- package/dist/server/middleware/require-super-admin.js.map +1 -0
- package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
- package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/dist/server/routes/admin.routes.d.ts +5 -0
- package/dist/server/routes/admin.routes.d.ts.map +1 -0
- package/dist/server/routes/admin.routes.js +262 -0
- package/dist/server/routes/admin.routes.js.map +1 -0
- package/dist/server/routes/audit.routes.d.ts +5 -0
- package/dist/server/routes/audit.routes.d.ts.map +1 -0
- package/dist/server/routes/audit.routes.js +70 -0
- package/dist/server/routes/audit.routes.js.map +1 -0
- package/dist/server/routes/health.routes.d.ts +8 -0
- package/dist/server/routes/health.routes.d.ts.map +1 -0
- package/dist/server/routes/health.routes.js +39 -0
- package/dist/server/routes/health.routes.js.map +1 -0
- package/dist/server/routes/invitations.routes.d.ts +5 -0
- package/dist/server/routes/invitations.routes.d.ts.map +1 -0
- package/dist/server/routes/invitations.routes.js +232 -0
- package/dist/server/routes/invitations.routes.js.map +1 -0
- package/dist/server/routes/me.routes.d.ts +5 -0
- package/dist/server/routes/me.routes.d.ts.map +1 -0
- package/dist/server/routes/me.routes.js +188 -0
- package/dist/server/routes/me.routes.js.map +1 -0
- package/dist/server/routes/orgs.routes.d.ts +5 -0
- package/dist/server/routes/orgs.routes.d.ts.map +1 -0
- package/dist/server/routes/orgs.routes.js +371 -0
- package/dist/server/routes/orgs.routes.js.map +1 -0
- package/dist/server/routes/transfer.routes.d.ts +5 -0
- package/dist/server/routes/transfer.routes.d.ts.map +1 -0
- package/dist/server/routes/transfer.routes.js +108 -0
- package/dist/server/routes/transfer.routes.js.map +1 -0
- package/dist/server/services/audit.service.d.ts +20 -0
- package/dist/server/services/audit.service.d.ts.map +1 -0
- package/dist/server/services/audit.service.js +23 -0
- package/dist/server/services/audit.service.js.map +1 -0
- package/dist/server/services/email-change.service.d.ts +16 -0
- package/dist/server/services/email-change.service.d.ts.map +1 -0
- package/dist/server/services/email-change.service.js +107 -0
- package/dist/server/services/email-change.service.js.map +1 -0
- package/dist/server/services/invitations.service.d.ts +41 -0
- package/dist/server/services/invitations.service.d.ts.map +1 -0
- package/dist/server/services/invitations.service.js +214 -0
- package/dist/server/services/invitations.service.js.map +1 -0
- package/dist/server/services/memberships.service.d.ts +27 -0
- package/dist/server/services/memberships.service.d.ts.map +1 -0
- package/dist/server/services/memberships.service.js +69 -0
- package/dist/server/services/memberships.service.js.map +1 -0
- package/dist/server/services/organizations.service.d.ts +19 -0
- package/dist/server/services/organizations.service.d.ts.map +1 -0
- package/dist/server/services/organizations.service.js +61 -0
- package/dist/server/services/organizations.service.js.map +1 -0
- package/dist/server/services/ownership.service.d.ts +19 -0
- package/dist/server/services/ownership.service.d.ts.map +1 -0
- package/dist/server/services/ownership.service.js +102 -0
- package/dist/server/services/ownership.service.js.map +1 -0
- package/dist/server/services/password-reset.service.d.ts +12 -0
- package/dist/server/services/password-reset.service.d.ts.map +1 -0
- package/dist/server/services/password-reset.service.js +54 -0
- package/dist/server/services/password-reset.service.js.map +1 -0
- package/dist/server/services/super-admin.service.d.ts +59 -0
- package/dist/server/services/super-admin.service.d.ts.map +1 -0
- package/dist/server/services/super-admin.service.js +187 -0
- package/dist/server/services/super-admin.service.js.map +1 -0
- package/dist/server/types.d.ts +186 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +6 -0
- package/dist/server/types.js.map +1 -0
- package/dist/shared/types.d.ts +23 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +6 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +56 -0
- package/src/client/api.ts +314 -0
- package/src/client/components/AuditEventRow.tsx +59 -0
- package/src/client/components/CascadePreview.tsx +36 -0
- package/src/client/components/DangerZoneCard.tsx +103 -0
- package/src/client/components/InvitationCodeDisplay.tsx +48 -0
- package/src/client/components/InviteForm.tsx +77 -0
- package/src/client/components/MemberRow.tsx +69 -0
- package/src/client/components/PendingTransferBanner.tsx +98 -0
- package/src/client/components/PlaceholderCard.tsx +26 -0
- package/src/client/components/RoleBadge.tsx +26 -0
- package/src/client/components/RoleSelect.tsx +35 -0
- package/src/client/hooks/.gitkeep +0 -0
- package/src/client/hooks/useCurrentMembership.ts +24 -0
- package/src/client/hooks/useMembers.ts +24 -0
- package/src/client/hooks/usePendingInvitations.ts +24 -0
- package/src/client/hooks/usePendingTransfer.ts +27 -0
- package/src/client/index.ts +80 -0
- package/src/client/pages/AuditLogPage.tsx +164 -0
- package/src/client/pages/EmailChangePage.tsx +144 -0
- package/src/client/pages/InvitationAcceptPage.tsx +163 -0
- package/src/client/pages/InvitationCodePage.tsx +108 -0
- package/src/client/pages/MembersPage.tsx +290 -0
- package/src/client/pages/OrgSettingsPage.tsx +185 -0
- package/src/client/pages/OwnershipTransferPage.tsx +163 -0
- package/src/client/pages/PasswordResetPage.tsx +104 -0
- package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
- package/src/client/pages/PlaceholderPage.tsx +20 -0
- package/src/client/pages/SuperAdminDashboard.tsx +401 -0
- package/src/client/types.ts +78 -0
- package/src/index.ts +24 -0
- package/src/server/crypto.ts +47 -0
- package/src/server/index.ts +167 -0
- package/src/server/middleware/require-membership.ts +48 -0
- package/src/server/middleware/require-role.ts +19 -0
- package/src/server/middleware/require-super-admin.ts +32 -0
- package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/src/server/migrations/0011_seed_super_admin.sql +15 -0
- package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/src/server/routes/admin.routes.ts +208 -0
- package/src/server/routes/audit.routes.ts +93 -0
- package/src/server/routes/health.routes.ts +46 -0
- package/src/server/routes/invitations.routes.ts +252 -0
- package/src/server/routes/me.routes.ts +143 -0
- package/src/server/routes/orgs.routes.ts +428 -0
- package/src/server/routes/transfer.routes.ts +110 -0
- package/src/server/services/.gitkeep +0 -0
- package/src/server/services/audit.service.ts +49 -0
- package/src/server/services/email-change.service.ts +178 -0
- package/src/server/services/invitations.service.ts +316 -0
- package/src/server/services/memberships.service.ts +129 -0
- package/src/server/services/organizations.service.ts +110 -0
- package/src/server/services/ownership.service.ts +170 -0
- package/src/server/services/password-reset.service.ts +94 -0
- package/src/server/services/super-admin.service.ts +321 -0
- package/src/server/sql/.gitkeep +0 -0
- package/src/server/types.ts +145 -0
- package/src/shared/types.ts +24 -0
- package/tests/integration/audit-fires.test.ts +288 -0
- package/tests/integration/cascade-preview.test.ts +157 -0
- package/tests/integration/email-change.test.ts +190 -0
- package/tests/integration/feature-flags.test.ts +213 -0
- package/tests/integration/invitations-code.test.ts +218 -0
- package/tests/integration/invitations-expiry.test.ts +216 -0
- package/tests/integration/invitations-resend.test.ts +241 -0
- package/tests/integration/invitations-revoke.test.ts +226 -0
- package/tests/integration/invitations-switch-org.test.ts +156 -0
- package/tests/integration/invitations-token.test.ts +221 -0
- package/tests/integration/migrations.test.ts +119 -0
- package/tests/integration/only-owner-protections.test.ts +130 -0
- package/tests/integration/org-lifecycle.test.ts +169 -0
- package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
- package/tests/integration/ownership-transfer-expire.test.ts +171 -0
- package/tests/integration/ownership-transfer-happy.test.ts +184 -0
- package/tests/integration/ownership-transfer-locks.test.ts +146 -0
- package/tests/integration/password-reset.test.ts +200 -0
- package/tests/integration/super-admin-actions.test.ts +180 -0
- package/tests/integration/super-admin-restrictions.test.ts +209 -0
- package/tests/setup/global-setup.ts +20 -0
- package/tests/unit/adapter-shape.test.ts +330 -0
- package/tests/unit/role-permissions.test.ts +236 -0
- package/tests/unit/validation.test.ts +304 -0
- package/tsconfig.client.json +13 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { OrgRole } from '../types.js';
|
|
3
|
+
import { createInvitation } from '../api.js';
|
|
4
|
+
import { RoleSelect } from './RoleSelect.js';
|
|
5
|
+
|
|
6
|
+
interface InviteFormProps {
|
|
7
|
+
orgId: number;
|
|
8
|
+
onSuccess: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function InviteForm({ orgId, onSuccess }: InviteFormProps) {
|
|
12
|
+
const [email, setEmail] = useState('');
|
|
13
|
+
const [role, setRole] = useState<OrgRole>('member');
|
|
14
|
+
const [submitting, setSubmitting] = useState(false);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
const [success, setSuccess] = useState(false);
|
|
17
|
+
|
|
18
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
if (!email.trim()) return;
|
|
21
|
+
|
|
22
|
+
setSubmitting(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
setSuccess(false);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await createInvitation(orgId, { email: email.trim(), role });
|
|
28
|
+
setEmail('');
|
|
29
|
+
setRole('member');
|
|
30
|
+
setSuccess(true);
|
|
31
|
+
onSuccess();
|
|
32
|
+
setTimeout(() => setSuccess(false), 3000);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : 'Failed to send invitation');
|
|
35
|
+
} finally {
|
|
36
|
+
setSubmitting(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form onSubmit={handleSubmit} className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm">
|
|
42
|
+
<h3 className="text-sm font-semibold text-slate-800 mb-3">Invite a team member</h3>
|
|
43
|
+
<div className="flex flex-col sm:flex-row gap-2">
|
|
44
|
+
<input
|
|
45
|
+
type="email"
|
|
46
|
+
placeholder="colleague@company.com"
|
|
47
|
+
value={email}
|
|
48
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
49
|
+
required
|
|
50
|
+
disabled={submitting}
|
|
51
|
+
className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-slate-50"
|
|
52
|
+
/>
|
|
53
|
+
<div className="w-full sm:w-36">
|
|
54
|
+
<RoleSelect
|
|
55
|
+
value={role}
|
|
56
|
+
onChange={setRole}
|
|
57
|
+
disabled={submitting}
|
|
58
|
+
disabledRoles={['owner']}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<button
|
|
62
|
+
type="submit"
|
|
63
|
+
disabled={submitting || !email.trim()}
|
|
64
|
+
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
65
|
+
>
|
|
66
|
+
{submitting ? 'Sending…' : 'Send Invite'}
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
{error && (
|
|
70
|
+
<p className="mt-2 text-sm text-red-600">{error}</p>
|
|
71
|
+
)}
|
|
72
|
+
{success && (
|
|
73
|
+
<p className="mt-2 text-sm text-green-600">Invitation sent successfully!</p>
|
|
74
|
+
)}
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { PublicMember, OrgRole } from '../types.js';
|
|
3
|
+
import { RoleBadge } from './RoleBadge.js';
|
|
4
|
+
import { RoleSelect } from './RoleSelect.js';
|
|
5
|
+
|
|
6
|
+
interface MemberRowProps {
|
|
7
|
+
member: PublicMember;
|
|
8
|
+
currentUserRole: OrgRole;
|
|
9
|
+
onRemove: (userId: number) => void;
|
|
10
|
+
onRoleChange: (userId: number, newRole: OrgRole) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const canManage = (currentRole: OrgRole): boolean =>
|
|
14
|
+
currentRole === 'owner' || currentRole === 'admin';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function formatDate(iso: string): string {
|
|
18
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
month: 'short',
|
|
21
|
+
day: 'numeric',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function MemberRow({ member, currentUserRole, onRemove, onRoleChange }: MemberRowProps) {
|
|
26
|
+
const canEdit = canManage(currentUserRole) && member.role !== 'owner';
|
|
27
|
+
const disabledRoles: OrgRole[] =
|
|
28
|
+
currentUserRole === 'admin' ? ['owner'] : [];
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<tr className="border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors">
|
|
32
|
+
<td className="py-3 px-4">
|
|
33
|
+
<div className="flex flex-col">
|
|
34
|
+
<span className="text-sm font-medium text-slate-900">
|
|
35
|
+
{member.name ?? member.email}
|
|
36
|
+
</span>
|
|
37
|
+
{member.name && (
|
|
38
|
+
<span className="text-xs text-slate-500">{member.email}</span>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</td>
|
|
42
|
+
<td className="py-3 px-4">
|
|
43
|
+
{canEdit ? (
|
|
44
|
+
<RoleSelect
|
|
45
|
+
value={member.role}
|
|
46
|
+
onChange={(r) => onRoleChange(member.user_id, r)}
|
|
47
|
+
disabledRoles={disabledRoles}
|
|
48
|
+
/>
|
|
49
|
+
) : (
|
|
50
|
+
<RoleBadge role={member.role} />
|
|
51
|
+
)}
|
|
52
|
+
</td>
|
|
53
|
+
<td className="py-3 px-4 text-sm text-slate-500 whitespace-nowrap">
|
|
54
|
+
{formatDate(member.joined_at)}
|
|
55
|
+
</td>
|
|
56
|
+
<td className="py-3 px-4 text-right">
|
|
57
|
+
{canEdit && (
|
|
58
|
+
<button
|
|
59
|
+
onClick={() => onRemove(member.user_id)}
|
|
60
|
+
className="text-sm text-red-600 hover:text-red-800 font-medium transition-colors"
|
|
61
|
+
>
|
|
62
|
+
Remove
|
|
63
|
+
</button>
|
|
64
|
+
)}
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { OwnershipTransfer } from '../types.js';
|
|
3
|
+
import { acceptTransfer, cancelTransfer } from '../api.js';
|
|
4
|
+
|
|
5
|
+
interface PendingTransferBannerProps {
|
|
6
|
+
transfer: OwnershipTransfer;
|
|
7
|
+
currentUserId: number;
|
|
8
|
+
orgId: number;
|
|
9
|
+
onAction: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatDate(iso: string): string {
|
|
13
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
14
|
+
month: 'short',
|
|
15
|
+
day: 'numeric',
|
|
16
|
+
hour: '2-digit',
|
|
17
|
+
minute: '2-digit',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PendingTransferBanner({
|
|
22
|
+
transfer,
|
|
23
|
+
currentUserId,
|
|
24
|
+
orgId,
|
|
25
|
+
onAction,
|
|
26
|
+
}: PendingTransferBannerProps) {
|
|
27
|
+
const [submitting, setSubmitting] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
const isTarget = currentUserId === transfer.to_user_id;
|
|
31
|
+
|
|
32
|
+
async function handleAccept() {
|
|
33
|
+
setSubmitting(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
try {
|
|
36
|
+
await acceptTransfer(orgId);
|
|
37
|
+
onAction();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setError(err instanceof Error ? err.message : 'Action failed');
|
|
40
|
+
} finally {
|
|
41
|
+
setSubmitting(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleDecline() {
|
|
46
|
+
setSubmitting(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
await cancelTransfer(orgId);
|
|
50
|
+
onAction();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err instanceof Error ? err.message : 'Action failed');
|
|
53
|
+
} finally {
|
|
54
|
+
setSubmitting(false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 shadow-sm">
|
|
60
|
+
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
61
|
+
<div className="flex-1">
|
|
62
|
+
<p className="text-sm font-semibold text-amber-900">
|
|
63
|
+
Pending Ownership Transfer
|
|
64
|
+
</p>
|
|
65
|
+
<p className="text-sm text-amber-800 mt-0.5">
|
|
66
|
+
{isTarget
|
|
67
|
+
? `You have been invited to become the new owner of this organization.`
|
|
68
|
+
: `An ownership transfer has been initiated to user #${transfer.to_user_id}.`}
|
|
69
|
+
</p>
|
|
70
|
+
<p className="text-xs text-amber-700 mt-1">
|
|
71
|
+
Expires {formatDate(transfer.expires_at)}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
{isTarget && (
|
|
75
|
+
<div className="flex gap-2 shrink-0">
|
|
76
|
+
<button
|
|
77
|
+
onClick={handleAccept}
|
|
78
|
+
disabled={submitting}
|
|
79
|
+
className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-50 transition-colors"
|
|
80
|
+
>
|
|
81
|
+
Accept
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
onClick={handleDecline}
|
|
85
|
+
disabled={submitting}
|
|
86
|
+
className="rounded-md border border-amber-400 bg-white px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100 focus:outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50 transition-colors"
|
|
87
|
+
>
|
|
88
|
+
Decline
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
{error && (
|
|
94
|
+
<p className="mt-2 text-sm text-red-600">{error}</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PlaceholderCard — rendered by host products while the full
|
|
5
|
+
* Team Management feature is in development.
|
|
6
|
+
*/
|
|
7
|
+
export function PlaceholderCard(): React.ReactElement {
|
|
8
|
+
return (
|
|
9
|
+
<div style={{
|
|
10
|
+
border: '1px solid #e2e8f0',
|
|
11
|
+
borderRadius: '8px',
|
|
12
|
+
padding: '24px',
|
|
13
|
+
maxWidth: '400px',
|
|
14
|
+
fontFamily: 'system-ui, sans-serif',
|
|
15
|
+
}}>
|
|
16
|
+
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👥</div>
|
|
17
|
+
<h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#1a2230' }}>
|
|
18
|
+
Team Management
|
|
19
|
+
</h3>
|
|
20
|
+
<p style={{ margin: 0, fontSize: '14px', color: '#64748b' }}>
|
|
21
|
+
Coming soon. Invite teammates, manage roles, and control access
|
|
22
|
+
to your Varshyl products.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { OrgRole } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const roleStyles: Record<OrgRole, string> = {
|
|
5
|
+
owner: 'bg-purple-100 text-purple-800 border border-purple-300',
|
|
6
|
+
admin: 'bg-blue-100 text-blue-800 border border-blue-300',
|
|
7
|
+
member: 'bg-green-100 text-green-800 border border-green-300',
|
|
8
|
+
viewer: 'bg-slate-100 text-slate-700 border border-slate-300',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const roleLabels: Record<OrgRole, string> = {
|
|
12
|
+
owner: 'Owner',
|
|
13
|
+
admin: 'Admin',
|
|
14
|
+
member: 'Member',
|
|
15
|
+
viewer: 'Viewer',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function RoleBadge({ role }: { role: OrgRole }) {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${roleStyles[role]}`}
|
|
22
|
+
>
|
|
23
|
+
{roleLabels[role]}
|
|
24
|
+
</span>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { OrgRole } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const ALL_ROLES: OrgRole[] = ['owner', 'admin', 'member', 'viewer'];
|
|
5
|
+
|
|
6
|
+
const roleLabels: Record<OrgRole, string> = {
|
|
7
|
+
owner: 'Owner',
|
|
8
|
+
admin: 'Admin',
|
|
9
|
+
member: 'Member',
|
|
10
|
+
viewer: 'Viewer',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface RoleSelectProps {
|
|
14
|
+
value: OrgRole;
|
|
15
|
+
onChange: (r: OrgRole) => void;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
disabledRoles?: OrgRole[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RoleSelect({ value, onChange, disabled, disabledRoles = [] }: RoleSelectProps) {
|
|
21
|
+
return (
|
|
22
|
+
<select
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(e) => onChange(e.target.value as OrgRole)}
|
|
25
|
+
disabled={disabled}
|
|
26
|
+
className="block w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
|
|
27
|
+
>
|
|
28
|
+
{ALL_ROLES.map((r) => (
|
|
29
|
+
<option key={r} value={r} disabled={disabledRoles.includes(r)}>
|
|
30
|
+
{roleLabels[r]}
|
|
31
|
+
</option>
|
|
32
|
+
))}
|
|
33
|
+
</select>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { getMyMembership } from '../api.js';
|
|
3
|
+
import type { CurrentMembership } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export function useCurrentMembership() {
|
|
6
|
+
const [membership, setMembership] = useState<CurrentMembership | null>(null);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
const load = useCallback(() => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
getMyMembership()
|
|
14
|
+
.then(setMembership)
|
|
15
|
+
.catch((e: Error) => setError(e.message))
|
|
16
|
+
.finally(() => setLoading(false));
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
load();
|
|
21
|
+
}, [load]);
|
|
22
|
+
|
|
23
|
+
return { membership, loading, error, refresh: load };
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { listMembers } from '../api.js';
|
|
3
|
+
import type { PublicMember } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export function useMembers(orgId: number, opts?: { includeFormer?: boolean }) {
|
|
6
|
+
const [members, setMembers] = useState<PublicMember[]>([]);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
const load = useCallback(() => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
listMembers(orgId, opts)
|
|
14
|
+
.then(setMembers)
|
|
15
|
+
.catch((e: Error) => setError(e.message))
|
|
16
|
+
.finally(() => setLoading(false));
|
|
17
|
+
}, [orgId, opts?.includeFormer]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
load();
|
|
21
|
+
}, [load]);
|
|
22
|
+
|
|
23
|
+
return { members, loading, error, refresh: load };
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { listInvitations } from '../api.js';
|
|
3
|
+
import type { PendingInvitation } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export function usePendingInvitations(orgId: number) {
|
|
6
|
+
const [invitations, setInvitations] = useState<PendingInvitation[]>([]);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
const load = useCallback(() => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
listInvitations(orgId)
|
|
14
|
+
.then(setInvitations)
|
|
15
|
+
.catch((e: Error) => setError(e.message))
|
|
16
|
+
.finally(() => setLoading(false));
|
|
17
|
+
}, [orgId]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
load();
|
|
21
|
+
}, [load]);
|
|
22
|
+
|
|
23
|
+
return { invitations, loading, error, refresh: load };
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { getPendingTransfer } from '../api.js';
|
|
3
|
+
import type { OwnershipTransfer } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export function usePendingTransfer(orgId: number) {
|
|
6
|
+
const [transfer, setTransfer] = useState<OwnershipTransfer | null>(null);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState<string | null>(null);
|
|
9
|
+
|
|
10
|
+
const load = useCallback(() => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
setError(null);
|
|
13
|
+
getPendingTransfer(orgId)
|
|
14
|
+
.then(setTransfer)
|
|
15
|
+
.catch((e: Error) => setError(e.message))
|
|
16
|
+
.finally(() => setLoading(false));
|
|
17
|
+
}, [orgId]);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
load();
|
|
21
|
+
// Poll every 30 seconds for transfer status changes
|
|
22
|
+
const interval = setInterval(load, 30_000);
|
|
23
|
+
return () => clearInterval(interval);
|
|
24
|
+
}, [load]);
|
|
25
|
+
|
|
26
|
+
return { transfer, loading, error, refresh: load };
|
|
27
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export { MembersPage } from './pages/MembersPage.js';
|
|
2
|
+
export { OrgSettingsPage } from './pages/OrgSettingsPage.js';
|
|
3
|
+
export { InvitationAcceptPage } from './pages/InvitationAcceptPage.js';
|
|
4
|
+
export { InvitationCodePage } from './pages/InvitationCodePage.js';
|
|
5
|
+
export { AuditLogPage } from './pages/AuditLogPage.js';
|
|
6
|
+
export { OwnershipTransferPage } from './pages/OwnershipTransferPage.js';
|
|
7
|
+
export { EmailChangePage } from './pages/EmailChangePage.js';
|
|
8
|
+
export { PasswordResetRequestPage } from './pages/PasswordResetRequestPage.js';
|
|
9
|
+
export { PasswordResetPage } from './pages/PasswordResetPage.js';
|
|
10
|
+
export { SuperAdminDashboard } from './pages/SuperAdminDashboard.js';
|
|
11
|
+
|
|
12
|
+
// Components
|
|
13
|
+
export { RoleBadge } from './components/RoleBadge.js';
|
|
14
|
+
export { RoleSelect } from './components/RoleSelect.js';
|
|
15
|
+
export { MemberRow } from './components/MemberRow.js';
|
|
16
|
+
export { InviteForm } from './components/InviteForm.js';
|
|
17
|
+
export { InvitationCodeDisplay } from './components/InvitationCodeDisplay.js';
|
|
18
|
+
export { AuditEventRow } from './components/AuditEventRow.js';
|
|
19
|
+
export { DangerZoneCard } from './components/DangerZoneCard.js';
|
|
20
|
+
export { CascadePreview } from './components/CascadePreview.js';
|
|
21
|
+
export { PendingTransferBanner } from './components/PendingTransferBanner.js';
|
|
22
|
+
|
|
23
|
+
// Hooks
|
|
24
|
+
export { useCurrentMembership } from './hooks/useCurrentMembership.js';
|
|
25
|
+
export { useMembers } from './hooks/useMembers.js';
|
|
26
|
+
export { usePendingInvitations } from './hooks/usePendingInvitations.js';
|
|
27
|
+
export { usePendingTransfer } from './hooks/usePendingTransfer.js';
|
|
28
|
+
|
|
29
|
+
// API
|
|
30
|
+
export {
|
|
31
|
+
setTmApiBase,
|
|
32
|
+
getOrg,
|
|
33
|
+
updateOrg,
|
|
34
|
+
deleteOrg,
|
|
35
|
+
listMembers,
|
|
36
|
+
removeMember,
|
|
37
|
+
changeMemberRole,
|
|
38
|
+
listInvitations,
|
|
39
|
+
createInvitation,
|
|
40
|
+
revokeInvitation,
|
|
41
|
+
resendInvitation,
|
|
42
|
+
getInvitationCode,
|
|
43
|
+
acceptInvitationByToken,
|
|
44
|
+
acceptInvitationByCode,
|
|
45
|
+
getMyMembership,
|
|
46
|
+
requestEmailChange,
|
|
47
|
+
verifyEmailChange,
|
|
48
|
+
cancelEmailChange,
|
|
49
|
+
requestPasswordReset,
|
|
50
|
+
resetPassword,
|
|
51
|
+
getPendingTransfer,
|
|
52
|
+
initiateTransfer,
|
|
53
|
+
acceptTransfer,
|
|
54
|
+
cancelTransfer,
|
|
55
|
+
getAuditLog,
|
|
56
|
+
adminListOrgs,
|
|
57
|
+
adminGetOrg,
|
|
58
|
+
adminRestoreOrg,
|
|
59
|
+
adminAppointOwner,
|
|
60
|
+
adminHardDeleteOrg,
|
|
61
|
+
adminAddMember,
|
|
62
|
+
adminRemoveMember,
|
|
63
|
+
adminLockUser,
|
|
64
|
+
adminUnlockUser,
|
|
65
|
+
adminResetPassword,
|
|
66
|
+
} from './api.js';
|
|
67
|
+
|
|
68
|
+
// Types
|
|
69
|
+
export type {
|
|
70
|
+
OrgRole,
|
|
71
|
+
TransferStatus,
|
|
72
|
+
PublicOrg,
|
|
73
|
+
PublicMember,
|
|
74
|
+
PendingInvitation,
|
|
75
|
+
AuditEvent,
|
|
76
|
+
OwnershipTransfer,
|
|
77
|
+
CurrentMembership,
|
|
78
|
+
SuperAdminOrgSummary,
|
|
79
|
+
ApiError,
|
|
80
|
+
} from './types.js';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useCurrentMembership } from '../hooks/useCurrentMembership.js';
|
|
3
|
+
import { AuditEventRow } from '../components/AuditEventRow.js';
|
|
4
|
+
import { getAuditLog } from '../api.js';
|
|
5
|
+
import type { AuditEvent } from '../types.js';
|
|
6
|
+
|
|
7
|
+
interface AuditLogPageProps {
|
|
8
|
+
orgId: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PAGE_SIZE = 25;
|
|
12
|
+
|
|
13
|
+
export function AuditLogPage({ orgId }: AuditLogPageProps) {
|
|
14
|
+
const { membership } = useCurrentMembership();
|
|
15
|
+
const [events, setEvents] = useState<AuditEvent[]>([]);
|
|
16
|
+
const [total, setTotal] = useState(0);
|
|
17
|
+
const [page, setPage] = useState(1);
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
const [actionFilter, setActionFilter] = useState('');
|
|
21
|
+
const [initialized, setInitialized] = useState(false);
|
|
22
|
+
|
|
23
|
+
const role = membership?.role;
|
|
24
|
+
const canView = role === 'owner' || role === 'admin';
|
|
25
|
+
|
|
26
|
+
async function load(p: number, action: string) {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
setError(null);
|
|
29
|
+
try {
|
|
30
|
+
const result = await getAuditLog(orgId, { page: p, limit: PAGE_SIZE, action: action || undefined });
|
|
31
|
+
setEvents(result.events);
|
|
32
|
+
setTotal(result.total);
|
|
33
|
+
setPage(result.page);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
setError(err instanceof Error ? err.message : 'Failed to load audit log');
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
setInitialized(true);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load on first render after membership check
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
if (canView && !initialized) {
|
|
45
|
+
load(1, actionFilter);
|
|
46
|
+
}
|
|
47
|
+
}, [canView]);
|
|
48
|
+
|
|
49
|
+
async function handleFilter(e: React.FormEvent) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
await load(1, actionFilter);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
55
|
+
|
|
56
|
+
if (!canView) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="max-w-4xl mx-auto px-4 py-8">
|
|
59
|
+
<div className="rounded-md bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
|
60
|
+
You do not have permission to view the audit log.
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
68
|
+
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-6">
|
|
69
|
+
<h1 className="text-2xl font-bold text-slate-900 flex-1">Audit Log</h1>
|
|
70
|
+
<form onSubmit={handleFilter} className="flex gap-2">
|
|
71
|
+
<input
|
|
72
|
+
type="text"
|
|
73
|
+
value={actionFilter}
|
|
74
|
+
onChange={(e) => setActionFilter(e.target.value)}
|
|
75
|
+
placeholder="Filter by action…"
|
|
76
|
+
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
type="submit"
|
|
80
|
+
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
81
|
+
>
|
|
82
|
+
Filter
|
|
83
|
+
</button>
|
|
84
|
+
{actionFilter && (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => { setActionFilter(''); load(1, ''); }}
|
|
88
|
+
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
|
89
|
+
>
|
|
90
|
+
Clear
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
</form>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{error && (
|
|
97
|
+
<div className="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 mb-4">
|
|
98
|
+
{error}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{loading ? (
|
|
103
|
+
<div className="flex items-center justify-center py-16">
|
|
104
|
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<div className="overflow-hidden rounded-lg border border-slate-200 shadow-sm">
|
|
109
|
+
<table className="w-full text-left">
|
|
110
|
+
<thead className="bg-slate-50 border-b border-slate-200">
|
|
111
|
+
<tr>
|
|
112
|
+
<th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Actor</th>
|
|
113
|
+
<th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
|
|
114
|
+
<th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">Target</th>
|
|
115
|
+
<th className="py-3 px-4 text-xs font-semibold uppercase tracking-wide text-slate-500">When</th>
|
|
116
|
+
</tr>
|
|
117
|
+
</thead>
|
|
118
|
+
<tbody className="bg-white divide-y divide-slate-100">
|
|
119
|
+
{events.map((event) => (
|
|
120
|
+
<AuditEventRow key={event.id} event={event} />
|
|
121
|
+
))}
|
|
122
|
+
{events.length === 0 && initialized && (
|
|
123
|
+
<tr>
|
|
124
|
+
<td colSpan={4} className="py-10 text-center text-sm text-slate-500">
|
|
125
|
+
No audit events found.
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
)}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Pagination */}
|
|
134
|
+
{totalPages > 1 && (
|
|
135
|
+
<div className="mt-4 flex items-center justify-between">
|
|
136
|
+
<p className="text-sm text-slate-600">
|
|
137
|
+
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total} events
|
|
138
|
+
</p>
|
|
139
|
+
<div className="flex gap-2">
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => load(page - 1, actionFilter)}
|
|
142
|
+
disabled={page <= 1 || loading}
|
|
143
|
+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
144
|
+
>
|
|
145
|
+
Previous
|
|
146
|
+
</button>
|
|
147
|
+
<span className="flex items-center px-2 text-sm text-slate-600">
|
|
148
|
+
{page} / {totalPages}
|
|
149
|
+
</span>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => load(page + 1, actionFilter)}
|
|
152
|
+
disabled={page >= totalPages || loading}
|
|
153
|
+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
154
|
+
>
|
|
155
|
+
Next
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|