create-astram-app 0.1.3 → 0.3.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/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +191 -0
- package/template/frontend/src/auth/AuthContext.jsx +343 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +103 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
- package/template/server/src/app.module.ts +28 -2
- package/template/server/src/health/demo.controller.ts +23 -12
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoleActionsMenu — the ⋯ row-action menu on the granted-roles listing.
|
|
3
|
+
* Edit permissions · Duplicate to another role · (sep) · Revoke all access (destructive).
|
|
4
|
+
* "View audit log" is intentionally reserved for the dedicated Audit feature (not wired).
|
|
5
|
+
*/
|
|
6
|
+
import { MoreHorizontal, Pencil, Copy, Trash2 } from 'lucide-react';
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '@/components/ui/dropdown-menu';
|
|
14
|
+
|
|
15
|
+
export default function RoleActionsMenu({ role, onEdit, onDuplicate, onRevokeAll }) {
|
|
16
|
+
return (
|
|
17
|
+
<DropdownMenu>
|
|
18
|
+
<DropdownMenuTrigger asChild>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
aria-label={`Actions for ${role.roleName}`}
|
|
22
|
+
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
23
|
+
>
|
|
24
|
+
<MoreHorizontal className="size-4" />
|
|
25
|
+
</button>
|
|
26
|
+
</DropdownMenuTrigger>
|
|
27
|
+
<DropdownMenuContent align="end" className="w-52">
|
|
28
|
+
<DropdownMenuItem onClick={() => onEdit(role)}>
|
|
29
|
+
<Pencil className="size-4" /> Edit permissions
|
|
30
|
+
</DropdownMenuItem>
|
|
31
|
+
<DropdownMenuItem onClick={() => onDuplicate(role)}>
|
|
32
|
+
<Copy className="size-4" /> Duplicate to another role
|
|
33
|
+
</DropdownMenuItem>
|
|
34
|
+
{/* View audit log — reserved for the Audit feature. */}
|
|
35
|
+
<DropdownMenuSeparator />
|
|
36
|
+
<DropdownMenuItem variant="destructive" onClick={() => onRevokeAll(role)}>
|
|
37
|
+
<Trash2 className="size-4" /> Revoke all access
|
|
38
|
+
</DropdownMenuItem>
|
|
39
|
+
</DropdownMenuContent>
|
|
40
|
+
</DropdownMenu>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AccountLayout — the persistent shell for the self-service account area. Replicates
|
|
3
|
+
* OCA's tabbed-account pattern with astram3's shadcn components: SEPARATE ROUTES per
|
|
4
|
+
* tab (bookmarkable, back-button works) under one layout that stays mounted.
|
|
5
|
+
*
|
|
6
|
+
* /account/profile → ProfilePage (index / default)
|
|
7
|
+
* /account/password → ChangePasswordPage
|
|
8
|
+
* /account/2fa → TwoFactorPage
|
|
9
|
+
*
|
|
10
|
+
* Layout (≥lg): LEFT = an identity card (avatar + display NAME + @user/email +
|
|
11
|
+
* company/role, from useAuth().profile — NAMES only, never a UUID) with a VERTICAL
|
|
12
|
+
* NavLink tab strip below it; RIGHT = <Outlet/>. ≤lg the identity card becomes a
|
|
13
|
+
* horizontal banner and the tabs a horizontal equal-width tile row (icon over label,
|
|
14
|
+
* bottom-underline active accent). All display values are resolved names — no raw IDs.
|
|
15
|
+
*/
|
|
16
|
+
import { KeyRound, ShieldCheck, User } from 'lucide-react';
|
|
17
|
+
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
|
18
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import AppShell from '@/shell/AppShell';
|
|
20
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
21
|
+
import { cn } from '@/lib/utils';
|
|
22
|
+
|
|
23
|
+
/** Two-letter initials from a display name (fallback 'U'), never a UUID. Mirrors Sidebar. */
|
|
24
|
+
function initialsOf(name) {
|
|
25
|
+
if (!name) return 'U';
|
|
26
|
+
const clean = name.includes('@') ? name.split('@')[0] : name;
|
|
27
|
+
const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
|
|
28
|
+
const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
|
|
29
|
+
return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TABS = [
|
|
33
|
+
{
|
|
34
|
+
to: '/account/profile',
|
|
35
|
+
icon: User,
|
|
36
|
+
label: 'Profile',
|
|
37
|
+
short: 'Profile',
|
|
38
|
+
desc: 'Your name and email',
|
|
39
|
+
subtitle: 'Update your display name and email.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
to: '/account/password',
|
|
43
|
+
icon: KeyRound,
|
|
44
|
+
label: 'Change password',
|
|
45
|
+
short: 'Password',
|
|
46
|
+
desc: 'Set a new password',
|
|
47
|
+
subtitle: 'Change your password and sign out other sessions.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
to: '/account/2fa',
|
|
51
|
+
icon: ShieldCheck,
|
|
52
|
+
label: 'Two-factor',
|
|
53
|
+
short: '2FA',
|
|
54
|
+
desc: 'Authenticator app',
|
|
55
|
+
subtitle: 'Add a one-time code as a second sign-in factor.',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** The signed-in user's identity card. `banner` = the ≤lg horizontal variant. */
|
|
60
|
+
function IdentityCard({ banner = false }) {
|
|
61
|
+
const { profile, identityHint } = useAuth();
|
|
62
|
+
const name = profile?.name || (identityHint ? identityHint.split('@')[0] : 'Signed in');
|
|
63
|
+
const handle = profile?.email || identityHint || '';
|
|
64
|
+
const company = profile?.company?.name || '';
|
|
65
|
+
const role = profile?.roles?.[0]?.name || '';
|
|
66
|
+
const initials = initialsOf(profile?.name || identityHint);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className={cn(
|
|
71
|
+
'rounded-xl border bg-card p-4 shadow-sm',
|
|
72
|
+
banner ? 'flex items-center gap-3' : 'flex flex-col items-center text-center',
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<Avatar
|
|
76
|
+
className={cn(
|
|
77
|
+
'shrink-0 rounded-xl ring-1 ring-primary/20',
|
|
78
|
+
banner ? 'size-11' : 'size-16',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<AvatarFallback
|
|
82
|
+
className={cn(
|
|
83
|
+
'rounded-xl bg-primary font-semibold text-primary-foreground',
|
|
84
|
+
banner ? 'text-sm' : 'text-lg',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{initials}
|
|
88
|
+
</AvatarFallback>
|
|
89
|
+
</Avatar>
|
|
90
|
+
<div className={cn('min-w-0', banner ? 'leading-tight' : 'mt-3 w-full')}>
|
|
91
|
+
<p className="truncate text-sm font-semibold text-foreground">{name}</p>
|
|
92
|
+
{handle && <p className="truncate text-xs text-muted-foreground">{handle}</p>}
|
|
93
|
+
{(company || role) && (
|
|
94
|
+
<p className="mt-0.5 truncate text-[11px] font-medium uppercase tracking-wide text-primary/80">
|
|
95
|
+
{[company, role].filter(Boolean).join(' · ')}
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default function AccountLayout() {
|
|
104
|
+
const location = useLocation();
|
|
105
|
+
const active = TABS.find((t) => location.pathname.startsWith(t.to));
|
|
106
|
+
const subtitle = active?.subtitle || 'Manage your credentials and security settings.';
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<AppShell section="Account" title={active?.label ?? 'Account'}>
|
|
110
|
+
<div className="p-4">
|
|
111
|
+
{/* Page header — matches Dashboard.jsx's header style. */}
|
|
112
|
+
<div className="mb-4">
|
|
113
|
+
<h1 className="text-xl font-bold tracking-tight">Account</h1>
|
|
114
|
+
<p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
|
118
|
+
{/* LEFT rail (≥lg): identity card + vertical tab strip. */}
|
|
119
|
+
<aside className="w-full shrink-0 lg:w-64">
|
|
120
|
+
{/* Identity: centered card ≥lg, horizontal banner ≤lg. */}
|
|
121
|
+
<div className="hidden lg:block">
|
|
122
|
+
<IdentityCard />
|
|
123
|
+
</div>
|
|
124
|
+
<div className="lg:hidden">
|
|
125
|
+
<IdentityCard banner />
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Vertical tab strip ≥lg. */}
|
|
129
|
+
<nav className="mt-4 hidden flex-col gap-1 lg:flex">
|
|
130
|
+
{TABS.map((tab) => {
|
|
131
|
+
const Icon = tab.icon;
|
|
132
|
+
return (
|
|
133
|
+
<NavLink key={tab.to} to={tab.to} className="block">
|
|
134
|
+
{({ isActive }) => (
|
|
135
|
+
<span
|
|
136
|
+
className={cn(
|
|
137
|
+
// Extra left padding (pl-5) leaves room between the inset accent
|
|
138
|
+
// bar and the icon/text; pr-3 keeps the right side compact.
|
|
139
|
+
'group relative flex items-start gap-3 rounded-lg border py-2.5 pl-5 pr-3 transition-colors',
|
|
140
|
+
isActive
|
|
141
|
+
? 'border-primary/30 bg-primary/10'
|
|
142
|
+
: 'border-transparent hover:bg-accent',
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{/* Left accent bar on the active tab — inset slightly from the
|
|
146
|
+
edge (left-1) so it floats off the card border. */}
|
|
147
|
+
<span
|
|
148
|
+
className={cn(
|
|
149
|
+
'absolute inset-y-1.5 left-1 w-1 rounded-full bg-primary transition-opacity',
|
|
150
|
+
isActive ? 'opacity-100' : 'opacity-0',
|
|
151
|
+
)}
|
|
152
|
+
/>
|
|
153
|
+
<Icon
|
|
154
|
+
className={cn(
|
|
155
|
+
'mt-0.5 size-4 shrink-0',
|
|
156
|
+
isActive ? 'text-primary' : 'text-muted-foreground',
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
159
|
+
<span className="min-w-0">
|
|
160
|
+
<span
|
|
161
|
+
className={cn(
|
|
162
|
+
'block text-sm font-semibold',
|
|
163
|
+
isActive ? 'text-foreground' : 'text-foreground/90',
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{tab.label}
|
|
167
|
+
</span>
|
|
168
|
+
<span className="block text-xs text-muted-foreground">{tab.desc}</span>
|
|
169
|
+
</span>
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
172
|
+
</NavLink>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</nav>
|
|
176
|
+
|
|
177
|
+
{/* Horizontal tab tiles ≤lg (icon over short label, bottom-underline active). */}
|
|
178
|
+
<nav className="mt-4 grid grid-cols-3 gap-2 lg:hidden">
|
|
179
|
+
{TABS.map((tab) => {
|
|
180
|
+
const Icon = tab.icon;
|
|
181
|
+
return (
|
|
182
|
+
<NavLink key={tab.to} to={tab.to} className="block">
|
|
183
|
+
{({ isActive }) => (
|
|
184
|
+
<span
|
|
185
|
+
className={cn(
|
|
186
|
+
'flex flex-col items-center gap-1.5 rounded-lg border px-2 py-3 text-center transition-colors',
|
|
187
|
+
isActive
|
|
188
|
+
? 'border-primary/30 bg-primary/10'
|
|
189
|
+
: 'border-border hover:bg-accent',
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<Icon
|
|
193
|
+
className={cn(
|
|
194
|
+
'size-5',
|
|
195
|
+
isActive ? 'text-primary' : 'text-muted-foreground',
|
|
196
|
+
)}
|
|
197
|
+
/>
|
|
198
|
+
<span
|
|
199
|
+
className={cn(
|
|
200
|
+
'text-xs font-medium',
|
|
201
|
+
isActive ? 'text-foreground' : 'text-muted-foreground',
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
{tab.short}
|
|
205
|
+
</span>
|
|
206
|
+
{/* Bottom underline accent on the active tile. */}
|
|
207
|
+
<span
|
|
208
|
+
className={cn(
|
|
209
|
+
'h-0.5 w-8 rounded-full bg-primary transition-opacity',
|
|
210
|
+
isActive ? 'opacity-100' : 'opacity-0',
|
|
211
|
+
)}
|
|
212
|
+
/>
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
</NavLink>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
</nav>
|
|
219
|
+
</aside>
|
|
220
|
+
|
|
221
|
+
{/* RIGHT: the active sub-page. */}
|
|
222
|
+
<div className="min-w-0 flex-1">
|
|
223
|
+
<Outlet />
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</AppShell>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChangePasswordPage — OCA's two-pane change-password treatment rebuilt in shadcn.
|
|
3
|
+
* Rendered inside <AccountLayout/>'s <Outlet/>.
|
|
4
|
+
*
|
|
5
|
+
* LEFT pane: the three password fields (Current / New / Confirm) with a show/hide eye
|
|
6
|
+
* toggle, a 4-segment strength meter (scored 0–4 from length + char-class variety),
|
|
7
|
+
* and the submit button. RIGHT pane (border-separated): a LIVE requirements checklist
|
|
8
|
+
* that ticks green as each rule is met.
|
|
9
|
+
*
|
|
10
|
+
* HARD-required to submit: new ≥ 8 chars, differs from current, confirmation matches.
|
|
11
|
+
* ADVISORY (feed the meter only): mixed case, has a number/symbol. Core change-password
|
|
12
|
+
* call + toast + clear-on-success + inline error are lifted verbatim from the former
|
|
13
|
+
* single-page Account.jsx ChangePasswordCard.
|
|
14
|
+
*/
|
|
15
|
+
import { useState } from 'react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
17
|
+
import { Check, Eye, EyeOff, KeyRound, Loader2, Minus, ShieldCheck } from 'lucide-react';
|
|
18
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import { Button } from '@/components/ui/button';
|
|
20
|
+
import { Input } from '@/components/ui/input';
|
|
21
|
+
import { Label } from '@/components/ui/label';
|
|
22
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
23
|
+
import { cn } from '@/lib/utils';
|
|
24
|
+
|
|
25
|
+
const MIN_PASSWORD_LEN = 8;
|
|
26
|
+
|
|
27
|
+
/** Score a password 0–4 from length + character-class variety (advisory UX signal). */
|
|
28
|
+
function scorePassword(pw) {
|
|
29
|
+
if (!pw) return 0;
|
|
30
|
+
let score = 0;
|
|
31
|
+
if (pw.length >= MIN_PASSWORD_LEN) score += 1;
|
|
32
|
+
if (pw.length >= 12) score += 1;
|
|
33
|
+
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
|
|
34
|
+
if (/\d/.test(pw) || /[^A-Za-z0-9]/.test(pw)) score += 1;
|
|
35
|
+
return Math.min(score, 4);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STRENGTH = [
|
|
39
|
+
{ label: '', bar: 'bg-transparent', text: 'text-muted-foreground' },
|
|
40
|
+
{ label: 'Weak', bar: 'bg-destructive', text: 'text-destructive' },
|
|
41
|
+
{ label: 'Fair', bar: 'bg-[var(--warn)]', text: 'text-[var(--warn)]' },
|
|
42
|
+
{ label: 'Good', bar: 'bg-primary', text: 'text-primary' },
|
|
43
|
+
{ label: 'Strong', bar: 'bg-[var(--ok)]', text: 'text-[var(--ok)]' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** A single live requirement row — Check (met) or a muted dash (not yet). */
|
|
47
|
+
function Rule({ met, children }) {
|
|
48
|
+
return (
|
|
49
|
+
<li className="flex items-center gap-2 text-sm">
|
|
50
|
+
{met ? (
|
|
51
|
+
<Check className="size-4 shrink-0 text-[var(--ok)]" />
|
|
52
|
+
) : (
|
|
53
|
+
<Minus className="size-4 shrink-0 text-muted-foreground/60" />
|
|
54
|
+
)}
|
|
55
|
+
<span className={met ? 'text-foreground' : 'text-muted-foreground'}>{children}</span>
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function ChangePasswordPage() {
|
|
61
|
+
const { tokens, account } = useAuth();
|
|
62
|
+
const [current, setCurrent] = useState('');
|
|
63
|
+
const [next, setNext] = useState('');
|
|
64
|
+
const [confirm, setConfirm] = useState('');
|
|
65
|
+
const [show, setShow] = useState(false);
|
|
66
|
+
const [error, setError] = useState('');
|
|
67
|
+
const [submitting, setSubmitting] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Live rule state.
|
|
70
|
+
const longEnough = next.length >= MIN_PASSWORD_LEN;
|
|
71
|
+
const mixedCase = /[a-z]/.test(next) && /[A-Z]/.test(next);
|
|
72
|
+
const hasNumberOrSymbol = /\d/.test(next) || /[^A-Za-z0-9]/.test(next);
|
|
73
|
+
const differs = next.length > 0 && next !== current;
|
|
74
|
+
const matches = next.length > 0 && next === confirm;
|
|
75
|
+
const score = scorePassword(next);
|
|
76
|
+
const strength = STRENGTH[score];
|
|
77
|
+
|
|
78
|
+
const reset = () => {
|
|
79
|
+
setCurrent('');
|
|
80
|
+
setNext('');
|
|
81
|
+
setConfirm('');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onSubmit = async (e) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setError('');
|
|
87
|
+
|
|
88
|
+
// Client-side pre-checks (server is authoritative; these are UX). Only the HARD
|
|
89
|
+
// rules gate submission — mixed-case / number are advisory (meter only).
|
|
90
|
+
if (!current || !next) {
|
|
91
|
+
setError('Please fill in your current and new password.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!longEnough) {
|
|
95
|
+
setError(`New password must be at least ${MIN_PASSWORD_LEN} characters.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!differs) {
|
|
99
|
+
setError('Your new password must be different from your current one.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!matches) {
|
|
103
|
+
setError('New password and confirmation do not match.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setSubmitting(true);
|
|
108
|
+
const r = await account.changePassword(tokens?.accessToken, current, next);
|
|
109
|
+
setSubmitting(false);
|
|
110
|
+
|
|
111
|
+
if (r.ok) {
|
|
112
|
+
reset();
|
|
113
|
+
toast.success('Password changed', {
|
|
114
|
+
description: 'Your other sessions have been signed out. This one stays active.',
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setError(r.error || 'Could not change password.');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Card>
|
|
123
|
+
<CardHeader>
|
|
124
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
125
|
+
<KeyRound className="size-4 text-primary" />
|
|
126
|
+
Change password
|
|
127
|
+
</CardTitle>
|
|
128
|
+
<CardDescription>
|
|
129
|
+
Set a new password. For your security, changing it signs out your other sessions —
|
|
130
|
+
this one stays active.
|
|
131
|
+
</CardDescription>
|
|
132
|
+
</CardHeader>
|
|
133
|
+
<CardContent>
|
|
134
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
135
|
+
{/* LEFT — inputs, strength meter, submit. */}
|
|
136
|
+
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
|
137
|
+
<div className="space-y-1.5">
|
|
138
|
+
<Label htmlFor="cp-current">Current password</Label>
|
|
139
|
+
<div className="relative">
|
|
140
|
+
<Input
|
|
141
|
+
id="cp-current"
|
|
142
|
+
type={show ? 'text' : 'password'}
|
|
143
|
+
autoComplete="current-password"
|
|
144
|
+
value={current}
|
|
145
|
+
onChange={(e) => setCurrent(e.target.value)}
|
|
146
|
+
className="pr-10"
|
|
147
|
+
/>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => setShow((v) => !v)}
|
|
151
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
152
|
+
aria-label={show ? 'Hide passwords' : 'Show passwords'}
|
|
153
|
+
>
|
|
154
|
+
{show ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div className="space-y-1.5">
|
|
160
|
+
<Label htmlFor="cp-new">New password</Label>
|
|
161
|
+
<Input
|
|
162
|
+
id="cp-new"
|
|
163
|
+
type={show ? 'text' : 'password'}
|
|
164
|
+
autoComplete="new-password"
|
|
165
|
+
value={next}
|
|
166
|
+
onChange={(e) => setNext(e.target.value)}
|
|
167
|
+
placeholder={`At least ${MIN_PASSWORD_LEN} characters`}
|
|
168
|
+
/>
|
|
169
|
+
{/* 4-segment strength meter. */}
|
|
170
|
+
<div className="pt-1">
|
|
171
|
+
<div className="flex gap-1.5">
|
|
172
|
+
{[1, 2, 3, 4].map((seg) => (
|
|
173
|
+
<span
|
|
174
|
+
key={seg}
|
|
175
|
+
className={cn(
|
|
176
|
+
'h-1.5 flex-1 rounded-full transition-colors',
|
|
177
|
+
next && seg <= score ? strength.bar : 'bg-muted',
|
|
178
|
+
)}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
{next && (
|
|
183
|
+
<p className={cn('mt-1 text-xs font-medium', strength.text)}>
|
|
184
|
+
{strength.label} password
|
|
185
|
+
</p>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="space-y-1.5">
|
|
191
|
+
<Label htmlFor="cp-confirm">Confirm new password</Label>
|
|
192
|
+
<Input
|
|
193
|
+
id="cp-confirm"
|
|
194
|
+
type={show ? 'text' : 'password'}
|
|
195
|
+
autoComplete="new-password"
|
|
196
|
+
value={confirm}
|
|
197
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{error && (
|
|
202
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
203
|
+
{error}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<Button type="submit" className="w-full sm:w-auto" disabled={submitting}>
|
|
208
|
+
{submitting ? (
|
|
209
|
+
<Loader2 className="size-4 animate-spin" />
|
|
210
|
+
) : (
|
|
211
|
+
<ShieldCheck className="size-4" />
|
|
212
|
+
)}
|
|
213
|
+
{submitting ? 'Changing…' : 'Change password'}
|
|
214
|
+
</Button>
|
|
215
|
+
</form>
|
|
216
|
+
|
|
217
|
+
{/* RIGHT — live requirements guide (border-separated on md+). */}
|
|
218
|
+
<div className="md:border-l md:pl-6">
|
|
219
|
+
<p className="text-sm font-medium text-foreground">Password requirements</p>
|
|
220
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
221
|
+
Updates as you type. Length, difference, and match are required.
|
|
222
|
+
</p>
|
|
223
|
+
<ul className="mt-3 space-y-2">
|
|
224
|
+
<Rule met={longEnough}>At least {MIN_PASSWORD_LEN} characters</Rule>
|
|
225
|
+
<Rule met={mixedCase}>Upper and lower case letters (recommended)</Rule>
|
|
226
|
+
<Rule met={hasNumberOrSymbol}>A number or symbol (recommended)</Rule>
|
|
227
|
+
<Rule met={differs}>Different from your current password</Rule>
|
|
228
|
+
<Rule met={matches}>New password and confirmation match</Rule>
|
|
229
|
+
</ul>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</CardContent>
|
|
233
|
+
</Card>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfilePage — self-service edit of the signed-in user's own display name + email.
|
|
3
|
+
* Rendered inside <AccountLayout/>'s <Outlet/> (the layout owns AppShell + the header).
|
|
4
|
+
*
|
|
5
|
+
* Pre-fills from the /auth/me profile (via useAuth().profile); on save it PATCHes
|
|
6
|
+
* /account/profile through the shared accountService, then calls refreshProfile() so
|
|
7
|
+
* the header/sidebar user card reflects the new name/email at once. Only sends the
|
|
8
|
+
* fields that actually changed (partial edit). All display values are NAMES (never
|
|
9
|
+
* UUIDs) — the profile the server returns is UUID-free by contract. (Logic lifted
|
|
10
|
+
* verbatim from the former single-page Account.jsx ProfileCard.)
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useState } from 'react';
|
|
13
|
+
import { toast } from 'sonner';
|
|
14
|
+
import { Loader2, ShieldCheck, UserRound } from 'lucide-react';
|
|
15
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Input } from '@/components/ui/input';
|
|
18
|
+
import { Label } from '@/components/ui/label';
|
|
19
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
20
|
+
|
|
21
|
+
export default function ProfilePage() {
|
|
22
|
+
const { tokens, account, profile, refreshProfile } = useAuth();
|
|
23
|
+
const [name, setName] = useState(profile?.name ?? '');
|
|
24
|
+
const [email, setEmail] = useState(profile?.email ?? '');
|
|
25
|
+
const [error, setError] = useState('');
|
|
26
|
+
const [submitting, setSubmitting] = useState(false);
|
|
27
|
+
|
|
28
|
+
const initialName = profile?.name ?? '';
|
|
29
|
+
const initialEmail = profile?.email ?? '';
|
|
30
|
+
const dirty = name.trim() !== initialName || email.trim() !== initialEmail;
|
|
31
|
+
|
|
32
|
+
// The profile may still be loading (null) when this first mounts (e.g. a hard page
|
|
33
|
+
// refresh). Seed the fields once it arrives / changes; a server-side refresh after
|
|
34
|
+
// save re-syncs the inputs to the new profile.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setName(profile?.name ?? '');
|
|
37
|
+
setEmail(profile?.email ?? '');
|
|
38
|
+
}, [profile?.name, profile?.email]);
|
|
39
|
+
|
|
40
|
+
const onSubmit = async (e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
setError('');
|
|
43
|
+
|
|
44
|
+
if (!name.trim()) {
|
|
45
|
+
setError('Please enter your name.');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const changes = {};
|
|
49
|
+
if (name.trim() !== initialName) changes.name = name.trim();
|
|
50
|
+
if (email.trim() !== initialEmail) changes.email = email.trim();
|
|
51
|
+
if (Object.keys(changes).length === 0) {
|
|
52
|
+
setError('Nothing to update.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setSubmitting(true);
|
|
57
|
+
const r = await account.updateProfile(tokens?.accessToken, changes);
|
|
58
|
+
setSubmitting(false);
|
|
59
|
+
|
|
60
|
+
if (r.ok) {
|
|
61
|
+
await refreshProfile();
|
|
62
|
+
toast.success('Profile updated');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setError(r.error || 'Could not update your profile.');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Card>
|
|
70
|
+
<CardHeader>
|
|
71
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
72
|
+
<UserRound className="size-4 text-primary" />
|
|
73
|
+
Profile
|
|
74
|
+
</CardTitle>
|
|
75
|
+
<CardDescription>
|
|
76
|
+
Update your display name and email. These appear across the app and in your account.
|
|
77
|
+
</CardDescription>
|
|
78
|
+
</CardHeader>
|
|
79
|
+
<CardContent>
|
|
80
|
+
<form onSubmit={onSubmit} className="max-w-xl space-y-4" noValidate>
|
|
81
|
+
<div className="space-y-1.5">
|
|
82
|
+
<Label htmlFor="pf-name">Name</Label>
|
|
83
|
+
<Input
|
|
84
|
+
id="pf-name"
|
|
85
|
+
type="text"
|
|
86
|
+
autoComplete="name"
|
|
87
|
+
value={name}
|
|
88
|
+
onChange={(e) => setName(e.target.value)}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="space-y-1.5">
|
|
92
|
+
<Label htmlFor="pf-email">Email</Label>
|
|
93
|
+
<Input
|
|
94
|
+
id="pf-email"
|
|
95
|
+
type="email"
|
|
96
|
+
autoComplete="email"
|
|
97
|
+
value={email}
|
|
98
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{error && (
|
|
103
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
104
|
+
{error}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="flex justify-end">
|
|
109
|
+
<Button type="submit" disabled={submitting || !dirty}>
|
|
110
|
+
{submitting ? (
|
|
111
|
+
<Loader2 className="size-4 animate-spin" />
|
|
112
|
+
) : (
|
|
113
|
+
<ShieldCheck className="size-4" />
|
|
114
|
+
)}
|
|
115
|
+
{submitting ? 'Saving…' : 'Save changes'}
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
</form>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
121
|
+
);
|
|
122
|
+
}
|