create-nala 3.1.0 → 3.1.2
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/package.json +1 -1
- package/template/.agents/skills/nala-project/SKILL.md +66 -21
- package/template/AGENTS.md +114 -14
- package/template/CLAUDE.md +83 -13
- package/template/GEMINI.md +83 -13
- package/template/README.md +1 -1
- package/template/package.json +8 -1
- package/template/src/components/BrandIcon.vue +258 -0
- package/template/src/components/ui/editor/RichTextEditor.vue +676 -0
- package/template/src/components/ui/editor/index.ts +2 -0
- package/template/src/components/ui/index.ts +1 -0
- package/template/src/components.d.ts +1 -0
- package/template/src/layouts/AuthSplitLayout.vue +216 -0
- package/template/src/router/index.ts +100 -0
- package/template/src/style.css +0 -2
- package/template/src/views/auth/LockScreenView.vue +1 -1
- package/template/src/views/auth/MagicLinkView.vue +278 -0
- package/template/src/views/auth/SelectTenantView.vue +673 -0
- package/template/src/views/auth/SsoLoginView.vue +337 -0
- package/template/src/views/auth/SuspendedView.vue +341 -0
- package/template/src/views/auth/TwoFactorChallengeView.vue +307 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Input } from '@/components/ui/input'
|
|
7
|
+
import { Label } from '@/components/ui/label'
|
|
8
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
9
|
+
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
10
|
+
import { toast } from '@/components/ui/sonner'
|
|
11
|
+
import {
|
|
12
|
+
ShieldCheck,
|
|
13
|
+
CheckCircle2,
|
|
14
|
+
ArrowRight,
|
|
15
|
+
ArrowLeft,
|
|
16
|
+
KeyRound,
|
|
17
|
+
Smartphone,
|
|
18
|
+
AlertCircle,
|
|
19
|
+
Loader2,
|
|
20
|
+
Shield,
|
|
21
|
+
Info,
|
|
22
|
+
} from '@lucide/vue'
|
|
23
|
+
|
|
24
|
+
const router = useRouter()
|
|
25
|
+
|
|
26
|
+
// Authentication state
|
|
27
|
+
const isRecoveryMode = ref(false)
|
|
28
|
+
const recoveryCode = ref('')
|
|
29
|
+
const digits = ref<string[]>(['', '', '', '', '', ''])
|
|
30
|
+
const inputRefs = ref<HTMLInputElement[]>([])
|
|
31
|
+
const trustDevice = ref(true)
|
|
32
|
+
const isVerifying = ref(false)
|
|
33
|
+
const isVerified = ref(false)
|
|
34
|
+
const errorMessage = ref('')
|
|
35
|
+
const userEmail = ref('alex.morgan@example.com')
|
|
36
|
+
|
|
37
|
+
// Check if input is complete
|
|
38
|
+
const isCodeComplete = computed(() => {
|
|
39
|
+
if (isRecoveryMode.value) {
|
|
40
|
+
return recoveryCode.value.trim().length >= 8
|
|
41
|
+
}
|
|
42
|
+
return digits.value.every((d: string) => d.length === 1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
focusFirstInput()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const setInputRef = (el: unknown, index: number) => {
|
|
50
|
+
if (el) {
|
|
51
|
+
inputRefs.value[index] = el as HTMLInputElement
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const focusFirstInput = () => {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
inputRefs.value[0]?.focus()
|
|
58
|
+
}, 150)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleDigitInput = (index: number, event: Event) => {
|
|
62
|
+
errorMessage.value = ''
|
|
63
|
+
const target = event.target as HTMLInputElement
|
|
64
|
+
const value = target.value.replace(/\D/g, '')
|
|
65
|
+
|
|
66
|
+
digits.value[index] = value ? value.slice(-1) : ''
|
|
67
|
+
|
|
68
|
+
// Auto-advance to next input
|
|
69
|
+
if (value && index < 5) {
|
|
70
|
+
inputRefs.value[index + 1]?.focus()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Auto-verify if all 6 digits are filled
|
|
74
|
+
if (digits.value.every((d: string) => d.length === 1)) {
|
|
75
|
+
handleVerify()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleKeyDown = (index: number, event: KeyboardEvent) => {
|
|
80
|
+
if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
|
|
81
|
+
inputRefs.value[index - 1]?.focus()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handlePaste = (event: ClipboardEvent) => {
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
const pastedData = event.clipboardData?.getData('text') || ''
|
|
88
|
+
const numbers = pastedData.replace(/\D/g, '').slice(0, 6)
|
|
89
|
+
|
|
90
|
+
if (numbers.length > 0) {
|
|
91
|
+
for (let i = 0; i < 6; i++) {
|
|
92
|
+
digits.value[i] = numbers[i] || ''
|
|
93
|
+
}
|
|
94
|
+
const nextIndex = Math.min(numbers.length, 5)
|
|
95
|
+
inputRefs.value[nextIndex]?.focus()
|
|
96
|
+
|
|
97
|
+
if (numbers.length === 6) {
|
|
98
|
+
handleVerify()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleVerify = () => {
|
|
104
|
+
if (!isCodeComplete.value || isVerifying.value) return
|
|
105
|
+
isVerifying.value = true
|
|
106
|
+
errorMessage.value = ''
|
|
107
|
+
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
isVerifying.value = false
|
|
110
|
+
const enteredCode = isRecoveryMode.value ? recoveryCode.value.trim() : digits.value.join('')
|
|
111
|
+
|
|
112
|
+
// Simulated test failure condition
|
|
113
|
+
if (enteredCode === '000000') {
|
|
114
|
+
errorMessage.value = 'Invalid verification code. Please check your authenticator app and try again.'
|
|
115
|
+
toast.error('Authentication failed. Invalid code.')
|
|
116
|
+
digits.value = ['', '', '', '', '', '']
|
|
117
|
+
focusFirstInput()
|
|
118
|
+
} else {
|
|
119
|
+
isVerified.value = true
|
|
120
|
+
toast.success('Two-factor authentication verified successfully!')
|
|
121
|
+
}
|
|
122
|
+
}, 1000)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const toggleRecoveryMode = () => {
|
|
126
|
+
isRecoveryMode.value = !isRecoveryMode.value
|
|
127
|
+
errorMessage.value = ''
|
|
128
|
+
if (!isRecoveryMode.value) {
|
|
129
|
+
digits.value = ['', '', '', '', '', '']
|
|
130
|
+
focusFirstInput()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const handleProceed = () => {
|
|
135
|
+
router.push('/')
|
|
136
|
+
}
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<template>
|
|
140
|
+
<Card flush class="w-full max-w-md shadow-xl border-border/80 bg-card">
|
|
141
|
+
<!-- Success State -->
|
|
142
|
+
<template v-if="isVerified">
|
|
143
|
+
<CardHeader class="pt-8 pb-6 px-6 sm:px-8 space-y-2 text-center">
|
|
144
|
+
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
|
145
|
+
<CheckCircle2 class="h-6 w-6" />
|
|
146
|
+
</div>
|
|
147
|
+
<CardTitle class="text-2xl font-bold tracking-tight">Identity verified</CardTitle>
|
|
148
|
+
<CardDescription class="text-sm">
|
|
149
|
+
Two-factor authentication verified successfully. Welcome back to your account!
|
|
150
|
+
</CardDescription>
|
|
151
|
+
</CardHeader>
|
|
152
|
+
|
|
153
|
+
<CardContent class="px-6 sm:px-8 pb-8 space-y-4">
|
|
154
|
+
<div class="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-4 text-xs text-muted-foreground">
|
|
155
|
+
<div class="flex items-center gap-2 font-medium text-emerald-600 dark:text-emerald-400 mb-1">
|
|
156
|
+
<Shield class="h-4 w-4" />
|
|
157
|
+
Device Trusted Session
|
|
158
|
+
</div>
|
|
159
|
+
{{ trustDevice ? 'This device has been marked as trusted for 30 days.' : 'Single session verified.' }}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<Button class="w-full font-medium cursor-pointer" @click="handleProceed">
|
|
163
|
+
<span class="flex items-center justify-center gap-2">
|
|
164
|
+
Continue to Dashboard
|
|
165
|
+
<ArrowRight class="h-4 w-4" />
|
|
166
|
+
</span>
|
|
167
|
+
</Button>
|
|
168
|
+
</CardContent>
|
|
169
|
+
</template>
|
|
170
|
+
|
|
171
|
+
<!-- Challenge State -->
|
|
172
|
+
<template v-else>
|
|
173
|
+
<CardHeader class="pt-8 pb-6 px-6 sm:px-8 space-y-1.5 text-center">
|
|
174
|
+
<div class="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
175
|
+
<component :is="isRecoveryMode ? KeyRound : ShieldCheck" class="h-6 w-6" />
|
|
176
|
+
</div>
|
|
177
|
+
<CardTitle class="text-2xl font-bold tracking-tight">
|
|
178
|
+
{{ isRecoveryMode ? 'Emergency Recovery Code' : 'Two-Factor Challenge' }}
|
|
179
|
+
</CardTitle>
|
|
180
|
+
<CardDescription class="text-sm">
|
|
181
|
+
<template v-if="isRecoveryMode">
|
|
182
|
+
Enter one of your 8-character backup codes saved during 2FA setup.
|
|
183
|
+
</template>
|
|
184
|
+
<template v-else>
|
|
185
|
+
Enter the 6-digit security code generated by your authenticator app for
|
|
186
|
+
<span class="font-medium text-foreground">{{ userEmail }}</span>.
|
|
187
|
+
</template>
|
|
188
|
+
</CardDescription>
|
|
189
|
+
</CardHeader>
|
|
190
|
+
|
|
191
|
+
<CardContent class="px-6 sm:px-8 pb-6 space-y-5">
|
|
192
|
+
<!-- Error Alert Banner -->
|
|
193
|
+
<Alert v-if="errorMessage" variant="destructive" class="py-2.5 px-3.5">
|
|
194
|
+
<AlertCircle class="h-4 w-4" />
|
|
195
|
+
<AlertDescription class="text-xs font-medium ml-2">
|
|
196
|
+
{{ errorMessage }}
|
|
197
|
+
</AlertDescription>
|
|
198
|
+
</Alert>
|
|
199
|
+
|
|
200
|
+
<!-- Standard TOTP Mode (6 Segments) -->
|
|
201
|
+
<div v-if="!isRecoveryMode" class="space-y-4">
|
|
202
|
+
<div class="flex items-center justify-between">
|
|
203
|
+
<Label class="text-xs font-medium text-muted-foreground">Authenticator Code</Label>
|
|
204
|
+
<span class="text-[11px] text-muted-foreground flex items-center gap-1">
|
|
205
|
+
<Smartphone class="h-3 w-3" />
|
|
206
|
+
TOTP App
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div class="flex justify-between items-center gap-2">
|
|
211
|
+
<input
|
|
212
|
+
v-for="(_, index) in digits"
|
|
213
|
+
:key="index"
|
|
214
|
+
:ref="(el) => setInputRef(el, index)"
|
|
215
|
+
type="text"
|
|
216
|
+
inputmode="numeric"
|
|
217
|
+
pattern="[0-9]*"
|
|
218
|
+
maxlength="1"
|
|
219
|
+
:value="digits[index]"
|
|
220
|
+
:disabled="isVerifying"
|
|
221
|
+
class="h-12 w-11 sm:w-12 text-center text-xl font-bold rounded-lg border border-input bg-background focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all disabled:opacity-50"
|
|
222
|
+
@input="handleDigitInput(index, $event)"
|
|
223
|
+
@keydown="handleKeyDown(index, $event)"
|
|
224
|
+
@paste="handlePaste"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<!-- Trust Device Option -->
|
|
229
|
+
<div class="flex items-center space-x-2 pt-1">
|
|
230
|
+
<Checkbox id="trust-device" v-model="trustDevice" />
|
|
231
|
+
<label
|
|
232
|
+
for="trust-device"
|
|
233
|
+
class="text-xs font-medium leading-none cursor-pointer text-muted-foreground select-none hover:text-foreground transition-colors"
|
|
234
|
+
>
|
|
235
|
+
Don't ask for codes on this device for 30 days
|
|
236
|
+
</label>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- Backup Recovery Code Mode -->
|
|
241
|
+
<div v-else class="space-y-3">
|
|
242
|
+
<div class="space-y-1.5">
|
|
243
|
+
<Label for="recovery-code" class="text-xs font-medium">Backup Recovery Code</Label>
|
|
244
|
+
<Input
|
|
245
|
+
id="recovery-code"
|
|
246
|
+
v-model="recoveryCode"
|
|
247
|
+
placeholder="e.g. NALA-2026-AUTH"
|
|
248
|
+
class="font-mono text-center tracking-wider uppercase h-11"
|
|
249
|
+
maxlength="20"
|
|
250
|
+
:disabled="isVerifying"
|
|
251
|
+
@keyup.enter="handleVerify"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<p class="text-[11px] text-muted-foreground text-center">
|
|
255
|
+
Each backup recovery code can only be used once.
|
|
256
|
+
</p>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<!-- Submit Button -->
|
|
260
|
+
<Button
|
|
261
|
+
class="w-full font-medium h-10 cursor-pointer"
|
|
262
|
+
:disabled="!isCodeComplete || isVerifying"
|
|
263
|
+
@click="handleVerify"
|
|
264
|
+
>
|
|
265
|
+
<Loader2 v-if="isVerifying" class="h-4 w-4 animate-spin mr-2" />
|
|
266
|
+
<span v-else class="flex items-center justify-center gap-1.5">
|
|
267
|
+
{{ isRecoveryMode ? 'Verify Recovery Code' : 'Verify & Continue' }}
|
|
268
|
+
<ArrowRight class="h-3.5 w-3.5" />
|
|
269
|
+
</span>
|
|
270
|
+
</Button>
|
|
271
|
+
|
|
272
|
+
<!-- Mode Switcher Trigger -->
|
|
273
|
+
<div class="pt-2 text-center">
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
class="text-xs text-primary hover:underline font-medium inline-flex items-center gap-1.5 cursor-pointer"
|
|
277
|
+
@click="toggleRecoveryMode"
|
|
278
|
+
>
|
|
279
|
+
<component :is="isRecoveryMode ? Smartphone : KeyRound" class="h-3.5 w-3.5" />
|
|
280
|
+
{{ isRecoveryMode ? 'Use 6-digit authenticator code instead' : "Lost phone? Use a backup recovery code" }}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- Demo Hint Box -->
|
|
285
|
+
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-[11px] text-muted-foreground space-y-1">
|
|
286
|
+
<div class="flex items-center gap-1.5 font-medium text-foreground">
|
|
287
|
+
<Info class="h-3.5 w-3.5 text-primary" />
|
|
288
|
+
<span>Demo Instructions</span>
|
|
289
|
+
</div>
|
|
290
|
+
<p>
|
|
291
|
+
Enter any 6 digits (e.g. <span class="font-mono font-semibold text-foreground">123456</span>) to succeed, or <span class="font-mono font-semibold text-destructive">000000</span> to test invalid code state.
|
|
292
|
+
</p>
|
|
293
|
+
</div>
|
|
294
|
+
</CardContent>
|
|
295
|
+
|
|
296
|
+
<CardFooter class="flex justify-center border-t border-border/60 py-4 bg-muted/10">
|
|
297
|
+
<router-link
|
|
298
|
+
to="/auth/login"
|
|
299
|
+
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
300
|
+
>
|
|
301
|
+
<ArrowLeft class="h-3.5 w-3.5" />
|
|
302
|
+
Return to sign in
|
|
303
|
+
</router-link>
|
|
304
|
+
</CardFooter>
|
|
305
|
+
</template>
|
|
306
|
+
</Card>
|
|
307
|
+
</template>
|