@djangocfg/layouts 2.1.264 → 2.1.266

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.
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { Loader2, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
3
+ import { Loader2, Shield, ShieldCheck, ShieldOff, Smartphone, Trash2 } from 'lucide-react';
4
4
  import React, { useEffect, useState } from 'react';
5
5
 
6
6
  import { useTwoFactorSetup, useTwoFactorStatus } from '@djangocfg/api/auth';
7
7
  import {
8
8
  Alert,
9
9
  AlertDescription,
10
+ Badge,
10
11
  Button,
11
12
  Card,
12
13
  CardContent,
@@ -20,279 +21,318 @@ import {
20
21
  DialogHeader,
21
22
  DialogTitle,
22
23
  OTPInput,
24
+ Separator,
23
25
  } from '@djangocfg/ui-core/components';
26
+ import { cn } from '@djangocfg/ui-core/lib';
24
27
 
25
- import { SetupStep } from '../../AuthLayout/components/steps/SetupStep';
28
+ import { SetupStepStandalone } from '../../AuthLayout/components/steps/SetupStep';
26
29
 
27
- type ViewState = 'status' | 'setup' | 'disable';
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Types
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ type View = 'status' | 'setup';
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Status badge
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ function StatusBadge({ enabled }: { enabled: boolean }) {
41
+ return (
42
+ <Badge
43
+ variant={enabled ? 'default' : 'secondary'}
44
+ className={cn(
45
+ 'text-xs font-medium',
46
+ enabled && 'bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20',
47
+ )}
48
+ >
49
+ {enabled ? 'Enabled' : 'Disabled'}
50
+ </Badge>
51
+ );
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Disable dialog
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ function DisableDialog({
59
+ open,
60
+ isLoading,
61
+ error,
62
+ onConfirm,
63
+ onCancel,
64
+ }: {
65
+ open: boolean;
66
+ isLoading: boolean;
67
+ error: string | null;
68
+ onConfirm: (code: string) => void;
69
+ onCancel: () => void;
70
+ }) {
71
+ const [code, setCode] = useState('');
72
+
73
+ // Reset on open
74
+ useEffect(() => {
75
+ if (open) setCode('');
76
+ }, [open]);
77
+
78
+ return (
79
+ <Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
80
+ <DialogContent className="sm:max-w-sm">
81
+ <DialogHeader>
82
+ <DialogTitle className="flex items-center gap-2">
83
+ <ShieldOff className="w-5 h-5 text-destructive" />
84
+ Disable Two-Factor Authentication
85
+ </DialogTitle>
86
+ <DialogDescription>
87
+ Enter the 6-digit code from your authenticator app to confirm.
88
+ This will make your account less secure.
89
+ </DialogDescription>
90
+ </DialogHeader>
91
+
92
+ <div className="py-2 space-y-4">
93
+ {error && (
94
+ <Alert variant="destructive">
95
+ <AlertDescription>{error}</AlertDescription>
96
+ </Alert>
97
+ )}
98
+ <div className="flex justify-center">
99
+ <OTPInput
100
+ length={6}
101
+ validationMode="numeric"
102
+ pasteBehavior="clean"
103
+ value={code}
104
+ onChange={setCode}
105
+ disabled={isLoading}
106
+ autoFocus
107
+ size="lg"
108
+ />
109
+ </div>
110
+ </div>
111
+
112
+ <DialogFooter>
113
+ <Button variant="outline" onClick={onCancel} disabled={isLoading}>
114
+ Cancel
115
+ </Button>
116
+ <Button
117
+ variant="destructive"
118
+ onClick={() => onConfirm(code)}
119
+ disabled={isLoading || code.length !== 6}
120
+ >
121
+ {isLoading ? (
122
+ <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Disabling…</>
123
+ ) : (
124
+ 'Disable 2FA'
125
+ )}
126
+ </Button>
127
+ </DialogFooter>
128
+ </DialogContent>
129
+ </Dialog>
130
+ );
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Device list row
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ function DeviceRow({ name, createdAt, isPrimary }: {
138
+ name: string;
139
+ createdAt: string;
140
+ isPrimary: boolean;
141
+ }) {
142
+ return (
143
+ <div className="flex items-center gap-3 py-3">
144
+ <div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center">
145
+ <Smartphone className="w-4 h-4 text-muted-foreground" />
146
+ </div>
147
+ <div className="flex-1 min-w-0">
148
+ <p className="text-sm font-medium truncate">{name}</p>
149
+ <p className="text-xs text-muted-foreground">
150
+ Added {new Date(createdAt).toLocaleDateString()}
151
+ {isPrimary && ' · Primary'}
152
+ </p>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Main component
160
+ // ─────────────────────────────────────────────────────────────────────────────
28
161
 
29
- /**
30
- * Two-Factor Authentication section for ProfileLayout.
31
- * Allows users to enable/disable 2FA from their profile.
32
- */
33
162
  export const TwoFactorSection: React.FC = () => {
34
- const [viewState, setViewState] = useState<ViewState>('status');
35
- const [disableCode, setDisableCode] = useState('');
36
- const [showDisableDialog, setShowDisableDialog] = useState(false);
163
+ const [view, setView] = useState<View>('status');
164
+ const [showDisable, setShowDisable] = useState(false);
37
165
 
38
166
  const {
39
- isLoading: statusLoading,
40
- error: statusError,
167
+ isLoading,
168
+ error,
41
169
  has2FAEnabled,
42
170
  devices,
43
171
  fetchStatus,
44
172
  disable2FA,
45
- clearError: clearStatusError,
173
+ clearError,
46
174
  } = useTwoFactorStatus();
47
175
 
48
- const {
49
- resetSetup,
50
- } = useTwoFactorSetup();
176
+ const { resetSetup } = useTwoFactorSetup();
51
177
 
52
- // Fetch status on mount
53
- useEffect(() => {
54
- fetchStatus();
55
- }, [fetchStatus]);
178
+ useEffect(() => { fetchStatus(); }, [fetchStatus]);
56
179
 
57
180
  const handleEnableClick = () => {
58
181
  resetSetup();
59
- setViewState('setup');
182
+ setView('setup');
60
183
  };
61
184
 
62
- const handleSetupComplete = () => {
63
- setViewState('status');
185
+ const handleSetupDone = () => {
186
+ setView('status');
64
187
  fetchStatus();
65
188
  };
66
189
 
67
- const handleSetupSkip = () => {
68
- setViewState('status');
69
- };
70
-
71
- const handleDisableClick = () => {
72
- setShowDisableDialog(true);
73
- setDisableCode('');
74
- clearStatusError();
75
- };
76
-
77
- const handleDisableConfirm = async () => {
78
- const success = await disable2FA(disableCode);
79
- if (success) {
80
- setShowDisableDialog(false);
81
- setDisableCode('');
82
- }
190
+ const handleDisableConfirm = async (code: string) => {
191
+ const ok = await disable2FA(code);
192
+ if (ok) setShowDisable(false);
83
193
  };
84
194
 
85
195
  const handleDisableCancel = () => {
86
- setShowDisableDialog(false);
87
- setDisableCode('');
88
- clearStatusError();
196
+ setShowDisable(false);
197
+ clearError();
89
198
  };
90
199
 
91
- // Show setup view
92
- if (viewState === 'setup') {
200
+ // ── Setup flow ──────────────────────────────────────────────────────────────
201
+ if (view === 'setup') {
93
202
  return (
94
- <Card className="bg-card/50 backdrop-blur-sm border-border/50">
203
+ <Card>
95
204
  <CardHeader>
96
- <CardTitle className="flex items-center gap-2">
97
- <Shield className="w-5 h-5" />
98
- Enable Two-Factor Authentication
99
- </CardTitle>
205
+ <div className="flex items-center justify-between">
206
+ <CardTitle className="flex items-center gap-2 text-base">
207
+ <Shield className="w-4 h-4" />
208
+ Set up Two-Factor Authentication
209
+ </CardTitle>
210
+ <Button variant="ghost" size="sm" onClick={() => setView('status')}>
211
+ Cancel
212
+ </Button>
213
+ </div>
100
214
  <CardDescription>
101
- Add an extra layer of security to your account
215
+ Scan the QR code with your authenticator app (Google Authenticator, Authy, etc.)
102
216
  </CardDescription>
103
217
  </CardHeader>
104
218
  <CardContent>
105
- <SetupStep
106
- onComplete={handleSetupComplete}
107
- onSkip={handleSetupSkip}
219
+ <SetupStepStandalone
220
+ onComplete={handleSetupDone}
221
+ onSkip={() => setView('status')}
108
222
  />
109
223
  </CardContent>
110
224
  </Card>
111
225
  );
112
226
  }
113
227
 
114
- // Loading state
115
- if (statusLoading && has2FAEnabled === null) {
228
+ // ── Loading skeleton ────────────────────────────────────────────────────────
229
+ if (isLoading && has2FAEnabled === null) {
116
230
  return (
117
- <Card className="bg-card/50 backdrop-blur-sm border-border/50">
118
- <CardContent className="flex items-center justify-center py-8">
119
- <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
231
+ <Card>
232
+ <CardContent className="flex items-center justify-center py-10">
233
+ <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
120
234
  </CardContent>
121
235
  </Card>
122
236
  );
123
237
  }
124
238
 
125
- // Main status view
239
+ // ── Status view ─────────────────────────────────────────────────────────────
126
240
  return (
127
241
  <>
128
- <Card className="bg-card/50 backdrop-blur-sm border-border/50">
242
+ <Card>
129
243
  <CardHeader>
130
- <CardTitle className="flex items-center gap-2">
244
+ <div className="flex items-start justify-between gap-4">
245
+ <div className="flex items-center gap-3">
246
+ {has2FAEnabled ? (
247
+ <div className="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
248
+ <ShieldCheck className="w-5 h-5 text-green-500" />
249
+ </div>
250
+ ) : (
251
+ <div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
252
+ <ShieldOff className="w-5 h-5 text-muted-foreground" />
253
+ </div>
254
+ )}
255
+ <div>
256
+ <div className="flex items-center gap-2">
257
+ <CardTitle className="text-base">Two-Factor Authentication</CardTitle>
258
+ <StatusBadge enabled={!!has2FAEnabled} />
259
+ </div>
260
+ <CardDescription className="mt-0.5">
261
+ {has2FAEnabled
262
+ ? `${devices.length} authenticator device${devices.length !== 1 ? 's' : ''} connected`
263
+ : 'Add an extra layer of security to your account'}
264
+ </CardDescription>
265
+ </div>
266
+ </div>
267
+
131
268
  {has2FAEnabled ? (
132
- <ShieldCheck className="w-5 h-5 text-green-500" />
269
+ <Button
270
+ variant="outline"
271
+ size="sm"
272
+ onClick={() => { clearError(); setShowDisable(true); }}
273
+ disabled={isLoading}
274
+ className="flex-shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
275
+ >
276
+ Disable
277
+ </Button>
133
278
  ) : (
134
- <ShieldOff className="w-5 h-5 text-muted-foreground" />
279
+ <Button size="sm" onClick={handleEnableClick} disabled={isLoading} className="flex-shrink-0">
280
+ Enable 2FA
281
+ </Button>
135
282
  )}
136
- Two-Factor Authentication
137
- </CardTitle>
138
- <CardDescription>
139
- {has2FAEnabled
140
- ? 'Your account is protected with two-factor authentication'
141
- : 'Add an extra layer of security to your account'}
142
- </CardDescription>
283
+ </div>
143
284
  </CardHeader>
144
285
 
145
- <CardContent className="space-y-4">
146
- {statusError && (
286
+ {/* Fetch error */}
287
+ {error && !showDisable && (
288
+ <CardContent className="pt-0">
147
289
  <Alert variant="destructive">
148
- <AlertDescription>{statusError}</AlertDescription>
290
+ <AlertDescription>{error}</AlertDescription>
149
291
  </Alert>
150
- )}
151
-
152
- {has2FAEnabled ? (
153
- <>
154
- {/* 2FA Enabled State */}
155
- <div className="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
156
- <div className="flex items-center gap-3">
157
- <ShieldCheck className="w-8 h-8 text-green-600 dark:text-green-400" />
158
- <div>
159
- <p className="font-medium text-green-800 dark:text-green-200">
160
- 2FA is enabled
161
- </p>
162
- <p className="text-sm text-green-600 dark:text-green-400">
163
- {devices.length} authenticator device{devices.length !== 1 ? 's' : ''} connected
164
- </p>
165
- </div>
166
- </div>
167
- <Button
168
- variant="outline"
169
- size="sm"
170
- onClick={handleDisableClick}
171
- disabled={statusLoading}
172
- className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
173
- >
174
- Disable 2FA
175
- </Button>
176
- </div>
177
-
178
- {/* Device list */}
179
- {devices.length > 0 && (
180
- <div className="space-y-2">
181
- <p className="text-sm font-medium text-muted-foreground">
182
- Connected Devices
183
- </p>
184
- <div className="space-y-2">
185
- {devices.map((device) => (
186
- <div
187
- key={device.id}
188
- className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
189
- >
190
- <div className="flex items-center gap-3">
191
- <Shield className="w-5 h-5 text-muted-foreground" />
192
- <div>
193
- <p className="font-medium text-sm">{device.name}</p>
194
- <p className="text-xs text-muted-foreground">
195
- Added {new Date(device.createdAt).toLocaleDateString()}
196
- {device.isPrimary && ' • Primary'}
197
- </p>
198
- </div>
199
- </div>
200
- </div>
201
- ))}
202
- </div>
203
- </div>
204
- )}
205
- </>
206
- ) : (
207
- <>
208
- {/* 2FA Disabled State */}
209
- <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
210
- <div className="flex items-center gap-3">
211
- <ShieldOff className="w-8 h-8 text-muted-foreground" />
212
- <div>
213
- <p className="font-medium">2FA is not enabled</p>
214
- <p className="text-sm text-muted-foreground">
215
- Protect your account with an authenticator app
216
- </p>
217
- </div>
218
- </div>
219
- <Button
220
- onClick={handleEnableClick}
221
- disabled={statusLoading}
222
- >
223
- Enable 2FA
224
- </Button>
225
- </div>
292
+ </CardContent>
293
+ )}
226
294
 
227
- {/* Security recommendation */}
228
- <Alert>
229
- <Shield className="w-4 h-4" />
230
- <AlertDescription>
231
- Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
232
- </AlertDescription>
233
- </Alert>
234
- </>
235
- )}
236
- </CardContent>
237
- </Card>
238
-
239
- {/* Disable 2FA Dialog */}
240
- <Dialog open={showDisableDialog} onOpenChange={setShowDisableDialog}>
241
- <DialogContent>
242
- <DialogHeader>
243
- <DialogTitle>Disable Two-Factor Authentication</DialogTitle>
244
- <DialogDescription>
245
- Enter the 6-digit code from your authenticator app to disable 2FA.
246
- This will make your account less secure.
247
- </DialogDescription>
248
- </DialogHeader>
249
-
250
- <div className="py-4">
251
- {statusError && (
252
- <Alert variant="destructive" className="mb-4">
253
- <AlertDescription>{statusError}</AlertDescription>
254
- </Alert>
255
- )}
295
+ {/* Device list */}
296
+ {has2FAEnabled && devices.length > 0 && (
297
+ <CardContent className="pt-0">
298
+ <Separator className="mb-1" />
299
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mt-3 mb-1">
300
+ Connected devices
301
+ </p>
302
+ <div className="divide-y">
303
+ {devices.map((device) => (
304
+ <DeviceRow
305
+ key={device.id}
306
+ name={device.name}
307
+ createdAt={device.createdAt}
308
+ isPrimary={device.isPrimary}
309
+ />
310
+ ))}
311
+ </div>
312
+ </CardContent>
313
+ )}
256
314
 
257
- <div className="flex justify-center">
258
- <OTPInput
259
- length={6}
260
- validationMode="numeric"
261
- pasteBehavior="clean"
262
- value={disableCode}
263
- onChange={setDisableCode}
264
- disabled={statusLoading}
265
- autoFocus={true}
266
- size="lg"
267
- />
315
+ {/* Not enabled — info callout */}
316
+ {!has2FAEnabled && (
317
+ <CardContent className="pt-0">
318
+ <div className="flex gap-3 p-3 rounded-lg bg-muted/50 text-sm text-muted-foreground">
319
+ <Shield className="w-4 h-4 mt-0.5 flex-shrink-0" />
320
+ <span>
321
+ Two-factor authentication adds a second verification step when signing in,
322
+ protecting your account even if your password is compromised.
323
+ </span>
268
324
  </div>
269
- </div>
325
+ </CardContent>
326
+ )}
327
+ </Card>
270
328
 
271
- <DialogFooter>
272
- <Button
273
- variant="outline"
274
- onClick={handleDisableCancel}
275
- disabled={statusLoading}
276
- >
277
- Cancel
278
- </Button>
279
- <Button
280
- variant="destructive"
281
- onClick={handleDisableConfirm}
282
- disabled={statusLoading || disableCode.length !== 6}
283
- >
284
- {statusLoading ? (
285
- <>
286
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
287
- Disabling...
288
- </>
289
- ) : (
290
- 'Disable 2FA'
291
- )}
292
- </Button>
293
- </DialogFooter>
294
- </DialogContent>
295
- </Dialog>
329
+ <DisableDialog
330
+ open={showDisable}
331
+ isLoading={isLoading}
332
+ error={error}
333
+ onConfirm={handleDisableConfirm}
334
+ onCancel={handleDisableCancel}
335
+ />
296
336
  </>
297
337
  );
298
338
  };