@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,144 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { requestEmailChange, verifyEmailChange, cancelEmailChange } from '../api.js';
|
|
3
|
+
|
|
4
|
+
interface EmailChangePageProps {
|
|
5
|
+
/** 'request' | 'verify' | 'cancel' */
|
|
6
|
+
mode: 'request' | 'verify' | 'cancel';
|
|
7
|
+
token?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function EmailChangePage({ mode, token }: EmailChangePageProps) {
|
|
11
|
+
const [newEmail, setNewEmail] = useState('');
|
|
12
|
+
const [submitting, setSubmitting] = useState(false);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [success, setSuccess] = useState(false);
|
|
15
|
+
|
|
16
|
+
// Auto-submit verify/cancel on mount
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if ((mode === 'verify' || mode === 'cancel') && token) {
|
|
19
|
+
setSubmitting(true);
|
|
20
|
+
const fn = mode === 'verify' ? verifyEmailChange : cancelEmailChange;
|
|
21
|
+
fn(token)
|
|
22
|
+
.then(() => setSuccess(true))
|
|
23
|
+
.catch((e: Error) => setError(e.message))
|
|
24
|
+
.finally(() => setSubmitting(false));
|
|
25
|
+
}
|
|
26
|
+
}, [mode, token]);
|
|
27
|
+
|
|
28
|
+
if (mode === 'verify' || mode === 'cancel') {
|
|
29
|
+
if (submitting) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
32
|
+
<div className="text-center">
|
|
33
|
+
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent mb-4" />
|
|
34
|
+
<p className="text-sm text-slate-600">
|
|
35
|
+
{mode === 'verify' ? 'Verifying your email change…' : 'Cancelling email change…'}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (success) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
44
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
45
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
46
|
+
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
47
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
48
|
+
</svg>
|
|
49
|
+
</div>
|
|
50
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">
|
|
51
|
+
{mode === 'verify' ? 'Email updated!' : 'Email change cancelled.'}
|
|
52
|
+
</h1>
|
|
53
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
54
|
+
{mode === 'verify'
|
|
55
|
+
? 'Your email address has been successfully updated.'
|
|
56
|
+
: 'The email change request has been cancelled. Your email remains unchanged.'}
|
|
57
|
+
</p>
|
|
58
|
+
<a href="/settings" className="inline-block rounded-md border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors">
|
|
59
|
+
Back to Settings
|
|
60
|
+
</a>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
67
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
68
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
|
69
|
+
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
70
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
71
|
+
</svg>
|
|
72
|
+
</div>
|
|
73
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Something went wrong</h1>
|
|
74
|
+
<p className="text-sm text-slate-600 mb-6">{error}</p>
|
|
75
|
+
<a href="/settings" className="inline-block rounded-md border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors">
|
|
76
|
+
Back to Settings
|
|
77
|
+
</a>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// mode === 'request'
|
|
84
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setSubmitting(true);
|
|
87
|
+
setError(null);
|
|
88
|
+
try {
|
|
89
|
+
await requestEmailChange(newEmail.trim());
|
|
90
|
+
setSuccess(true);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setError(err instanceof Error ? err.message : 'Failed to request email change');
|
|
93
|
+
} finally {
|
|
94
|
+
setSubmitting(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (success) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="max-w-lg mx-auto px-4 py-8 text-center">
|
|
101
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
102
|
+
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
103
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
104
|
+
</svg>
|
|
105
|
+
</div>
|
|
106
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Check your email</h1>
|
|
107
|
+
<p className="text-sm text-slate-600">
|
|
108
|
+
We sent a confirmation link to <strong>{newEmail}</strong>. Click the link to confirm your new email address.
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="max-w-lg mx-auto px-4 py-8">
|
|
116
|
+
<h1 className="text-2xl font-bold text-slate-900 mb-2">Change Email Address</h1>
|
|
117
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
118
|
+
Enter your new email address. We will send a confirmation link to verify the change.
|
|
119
|
+
</p>
|
|
120
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
121
|
+
<div>
|
|
122
|
+
<label className="block text-sm font-medium text-slate-700 mb-1">New Email Address</label>
|
|
123
|
+
<input
|
|
124
|
+
type="email"
|
|
125
|
+
value={newEmail}
|
|
126
|
+
onChange={(e) => setNewEmail(e.target.value)}
|
|
127
|
+
required
|
|
128
|
+
disabled={submitting}
|
|
129
|
+
placeholder="new@email.com"
|
|
130
|
+
className="w-full 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 disabled:bg-slate-50"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
134
|
+
<button
|
|
135
|
+
type="submit"
|
|
136
|
+
disabled={submitting || !newEmail.trim()}
|
|
137
|
+
className="rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
|
|
138
|
+
>
|
|
139
|
+
{submitting ? 'Sending…' : 'Send Confirmation'}
|
|
140
|
+
</button>
|
|
141
|
+
</form>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { acceptInvitationByToken } from '../api.js';
|
|
3
|
+
import type { OrgRole } from '../types.js';
|
|
4
|
+
|
|
5
|
+
interface InvitationAcceptPageProps {
|
|
6
|
+
/** The magic-link token, typically extracted from the URL query string */
|
|
7
|
+
token: string;
|
|
8
|
+
/** Whether the current user is authenticated */
|
|
9
|
+
isAuthenticated: boolean;
|
|
10
|
+
loginUrl?: string;
|
|
11
|
+
signupUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type State =
|
|
15
|
+
| { status: 'loading' }
|
|
16
|
+
| { status: 'success'; orgId: number; role: OrgRole }
|
|
17
|
+
| { status: 'already_member'; message: string }
|
|
18
|
+
| { status: 'error'; message: string }
|
|
19
|
+
| { status: 'unauthenticated' };
|
|
20
|
+
|
|
21
|
+
export function InvitationAcceptPage({
|
|
22
|
+
token,
|
|
23
|
+
isAuthenticated,
|
|
24
|
+
loginUrl = '/login',
|
|
25
|
+
signupUrl = '/signup',
|
|
26
|
+
}: InvitationAcceptPageProps) {
|
|
27
|
+
const [state, setState] = useState<State>(
|
|
28
|
+
isAuthenticated ? { status: 'loading' } : { status: 'unauthenticated' }
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!isAuthenticated || !token) return;
|
|
33
|
+
|
|
34
|
+
acceptInvitationByToken(token)
|
|
35
|
+
.then(({ orgId, role }) => setState({ status: 'success', orgId, role }))
|
|
36
|
+
.catch((err: Error) => {
|
|
37
|
+
const msg = err.message ?? 'Something went wrong';
|
|
38
|
+
if (msg.includes('already') || msg.includes('member')) {
|
|
39
|
+
setState({ status: 'already_member', message: msg });
|
|
40
|
+
} else {
|
|
41
|
+
setState({ status: 'error', message: msg });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}, [token, isAuthenticated]);
|
|
45
|
+
|
|
46
|
+
if (state.status === 'unauthenticated') {
|
|
47
|
+
return (
|
|
48
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
49
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
50
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
|
51
|
+
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
52
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
53
|
+
</svg>
|
|
54
|
+
</div>
|
|
55
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">You have been invited!</h1>
|
|
56
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
57
|
+
Please log in or create an account to accept this invitation.
|
|
58
|
+
</p>
|
|
59
|
+
<div className="flex flex-col gap-3">
|
|
60
|
+
<a
|
|
61
|
+
href={`${loginUrl}?redirect_token=${encodeURIComponent(token)}`}
|
|
62
|
+
className="rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 text-center transition-colors"
|
|
63
|
+
>
|
|
64
|
+
Log In
|
|
65
|
+
</a>
|
|
66
|
+
<a
|
|
67
|
+
href={`${signupUrl}?redirect_token=${encodeURIComponent(token)}`}
|
|
68
|
+
className="rounded-md border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 text-center transition-colors"
|
|
69
|
+
>
|
|
70
|
+
Create Account
|
|
71
|
+
</a>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (state.status === 'loading') {
|
|
79
|
+
return (
|
|
80
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
81
|
+
<div className="text-center">
|
|
82
|
+
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent mb-4" />
|
|
83
|
+
<p className="text-sm text-slate-600">Accepting your invitation…</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (state.status === 'success') {
|
|
90
|
+
const roleLabels: Record<OrgRole, string> = {
|
|
91
|
+
owner: 'Owner',
|
|
92
|
+
admin: 'Admin',
|
|
93
|
+
member: 'Member',
|
|
94
|
+
viewer: 'Viewer',
|
|
95
|
+
};
|
|
96
|
+
return (
|
|
97
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
98
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
99
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
100
|
+
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
101
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
102
|
+
</svg>
|
|
103
|
+
</div>
|
|
104
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Welcome aboard!</h1>
|
|
105
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
106
|
+
You have joined the organization as <strong>{roleLabels[state.role]}</strong>.
|
|
107
|
+
</p>
|
|
108
|
+
<a
|
|
109
|
+
href={`/orgs/${state.orgId}`}
|
|
110
|
+
className="inline-block rounded-md bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
111
|
+
>
|
|
112
|
+
Go to Organization
|
|
113
|
+
</a>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (state.status === 'already_member') {
|
|
120
|
+
return (
|
|
121
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
122
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
123
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
|
124
|
+
<svg className="h-6 w-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
125
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
126
|
+
</svg>
|
|
127
|
+
</div>
|
|
128
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Already a member</h1>
|
|
129
|
+
<p className="text-sm text-slate-600 mb-6">{state.message}</p>
|
|
130
|
+
<a
|
|
131
|
+
href="/"
|
|
132
|
+
className="inline-block rounded-md border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
|
|
133
|
+
>
|
|
134
|
+
Back to Home
|
|
135
|
+
</a>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// error state
|
|
142
|
+
return (
|
|
143
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
144
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
145
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
|
146
|
+
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
147
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
148
|
+
</svg>
|
|
149
|
+
</div>
|
|
150
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Invitation failed</h1>
|
|
151
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
152
|
+
{'message' in state ? state.message : 'This invitation link is invalid or has expired.'}
|
|
153
|
+
</p>
|
|
154
|
+
<a
|
|
155
|
+
href="/"
|
|
156
|
+
className="inline-block rounded-md border border-slate-300 px-6 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
|
|
157
|
+
>
|
|
158
|
+
Back to Home
|
|
159
|
+
</a>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { acceptInvitationByCode } from '../api.js';
|
|
3
|
+
import type { OrgRole } from '../types.js';
|
|
4
|
+
|
|
5
|
+
interface InvitationCodePageProps {
|
|
6
|
+
prefillEmail?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type State =
|
|
10
|
+
| { status: 'idle' }
|
|
11
|
+
| { status: 'submitting' }
|
|
12
|
+
| { status: 'success'; orgId: number; role: OrgRole }
|
|
13
|
+
| { status: 'error'; message: string };
|
|
14
|
+
|
|
15
|
+
export function InvitationCodePage({ prefillEmail = '' }: InvitationCodePageProps) {
|
|
16
|
+
const [email, setEmail] = useState(prefillEmail);
|
|
17
|
+
const [code, setCode] = useState('');
|
|
18
|
+
const [state, setState] = useState<State>({ status: 'idle' });
|
|
19
|
+
|
|
20
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
setState({ status: 'submitting' });
|
|
23
|
+
try {
|
|
24
|
+
const result = await acceptInvitationByCode(email.trim(), code.trim());
|
|
25
|
+
setState({ status: 'success', ...result });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
setState({ status: 'error', message: err instanceof Error ? err.message : 'Invalid code' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (state.status === 'success') {
|
|
32
|
+
const roleLabels: Record<OrgRole, string> = {
|
|
33
|
+
owner: 'Owner', admin: 'Admin', member: 'Member', viewer: 'Viewer',
|
|
34
|
+
};
|
|
35
|
+
return (
|
|
36
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
37
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8 text-center">
|
|
38
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
39
|
+
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
40
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
41
|
+
</svg>
|
|
42
|
+
</div>
|
|
43
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">You're in!</h1>
|
|
44
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
45
|
+
You joined as <strong>{roleLabels[state.role]}</strong>.
|
|
46
|
+
</p>
|
|
47
|
+
<a
|
|
48
|
+
href={`/orgs/${state.orgId}`}
|
|
49
|
+
className="inline-block rounded-md bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
50
|
+
>
|
|
51
|
+
Go to Organization
|
|
52
|
+
</a>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const submitting = state.status === 'submitting';
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
62
|
+
<div className="bg-white rounded-xl shadow-md w-full max-w-md p-8">
|
|
63
|
+
<h1 className="text-xl font-bold text-slate-900 mb-2">Enter Invitation Code</h1>
|
|
64
|
+
<p className="text-sm text-slate-600 mb-6">
|
|
65
|
+
Enter the email address your invitation was sent to and the 6-digit code you received.
|
|
66
|
+
</p>
|
|
67
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
68
|
+
<div>
|
|
69
|
+
<label className="block text-sm font-medium text-slate-700 mb-1">Email Address</label>
|
|
70
|
+
<input
|
|
71
|
+
type="email"
|
|
72
|
+
value={email}
|
|
73
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
74
|
+
required
|
|
75
|
+
disabled={submitting}
|
|
76
|
+
placeholder="you@company.com"
|
|
77
|
+
className="w-full 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 disabled:bg-slate-50"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div>
|
|
81
|
+
<label className="block text-sm font-medium text-slate-700 mb-1">6-Digit Code</label>
|
|
82
|
+
<input
|
|
83
|
+
type="text"
|
|
84
|
+
value={code}
|
|
85
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
86
|
+
required
|
|
87
|
+
disabled={submitting}
|
|
88
|
+
placeholder="123456"
|
|
89
|
+
maxLength={6}
|
|
90
|
+
inputMode="numeric"
|
|
91
|
+
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm font-mono tracking-widest text-center focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-slate-50"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
{state.status === 'error' && (
|
|
95
|
+
<p className="text-sm text-red-600">{state.message}</p>
|
|
96
|
+
)}
|
|
97
|
+
<button
|
|
98
|
+
type="submit"
|
|
99
|
+
disabled={submitting || code.length !== 6 || !email.trim()}
|
|
100
|
+
className="w-full rounded-md bg-blue-600 px-4 py-2.5 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"
|
|
101
|
+
>
|
|
102
|
+
{submitting ? 'Verifying…' : 'Accept Invitation'}
|
|
103
|
+
</button>
|
|
104
|
+
</form>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|