Skip to content

Commit 62c4b52

Browse files
committed
feat: add PinInput component and update TwoFactor UI with reka-ui 2.4.1 enhancements
1 parent c284c85 commit 62c4b52

File tree

8 files changed

+125
-61
lines changed

8 files changed

+125
-61
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"concurrently": "^9.0.1",
3333
"laravel-vite-plugin": "^2.0.0",
3434
"lucide-vue-next": "^0.468.0",
35-
"reka-ui": "^2.2.0",
35+
"reka-ui": "^2.4.1",
3636
"tailwind-merge": "^3.2.0",
3737
"tailwindcss": "^4.1.1",
3838
"tw-animate-css": "^1.2.5",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script setup lang="ts">
2+
import type { PinInputRootEmits, PinInputRootProps } from "reka-ui"
3+
import type { HTMLAttributes } from "vue"
4+
import { reactiveOmit } from "@vueuse/core"
5+
import { PinInputRoot, useForwardPropsEmits } from "reka-ui"
6+
import { cn } from "@/lib/utils"
7+
8+
const props = withDefaults(defineProps<PinInputRootProps & { class?: HTMLAttributes["class"] }>(), {
9+
modelValue: () => [],
10+
})
11+
const emits = defineEmits<PinInputRootEmits>()
12+
13+
const delegatedProps = reactiveOmit(props, "class")
14+
15+
const forwarded = useForwardPropsEmits(delegatedProps, emits)
16+
</script>
17+
18+
<template>
19+
<PinInputRoot
20+
data-slot="pin-input"
21+
v-bind="forwarded" :class="cn('flex items-center gap-2 has-disabled:opacity-50 disabled:cursor-not-allowed', props.class)"
22+
>
23+
<slot />
24+
</PinInputRoot>
25+
</template>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import type { PrimitiveProps } from "reka-ui"
3+
import type { HTMLAttributes } from "vue"
4+
import { reactiveOmit } from "@vueuse/core"
5+
import { Primitive, useForwardProps } from "reka-ui"
6+
import { cn } from "@/lib/utils"
7+
8+
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
9+
const delegatedProps = reactiveOmit(props, "class")
10+
const forwardedProps = useForwardProps(delegatedProps)
11+
</script>
12+
13+
<template>
14+
<Primitive
15+
data-slot="pin-input-group"
16+
v-bind="forwardedProps"
17+
:class="cn('flex items-center', props.class)"
18+
>
19+
<slot />
20+
</Primitive>
21+
</template>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script setup lang="ts">
2+
import type { PrimitiveProps } from "reka-ui"
3+
import { Minus } from "lucide-vue-next"
4+
import { Primitive, useForwardProps } from "reka-ui"
5+
6+
const props = defineProps<PrimitiveProps>()
7+
const forwardedProps = useForwardProps(props)
8+
</script>
9+
10+
<template>
11+
<Primitive
12+
data-slot="pin-input-separator"
13+
v-bind="forwardedProps"
14+
>
15+
<slot>
16+
<Minus />
17+
</slot>
18+
</Primitive>
19+
</template>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import type { PinInputInputProps } from "reka-ui"
3+
import type { HTMLAttributes } from "vue"
4+
import { reactiveOmit } from "@vueuse/core"
5+
import { PinInputInput, useForwardProps } from "reka-ui"
6+
import { cn } from "@/lib/utils"
7+
8+
const props = defineProps<PinInputInputProps & { class?: HTMLAttributes["class"] }>()
9+
10+
const delegatedProps = reactiveOmit(props, "class")
11+
12+
const forwardedProps = useForwardProps(delegatedProps)
13+
</script>
14+
15+
<template>
16+
<PinInputInput
17+
data-slot="pin-input-slot"
18+
v-bind="forwardedProps"
19+
:class="cn('border-input focus:border-ring focus:ring-ring/50 focus:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:focus:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus:aria-invalid:border-destructive relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none text-center first:rounded-l-md first:border-l last:rounded-r-md focus:z-10 focus:ring-[3px]', props.class)"
20+
/>
21+
</template>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as PinInput } from "./PinInput.vue"
2+
export { default as PinInputGroup } from "./PinInputGroup.vue"
3+
export { default as PinInputSeparator } from "./PinInputSeparator.vue"
4+
export { default as PinInputSlot } from "./PinInputSlot.vue"

resources/js/pages/settings/TwoFactor.vue

Lines changed: 33 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,10 @@ const verifyStep = ref(false);
3939
const showingRecoveryCodes = ref(false);
4040
const showModal = ref(false);
4141
42-
// Form refs
43-
const enableFormRef = ref();
44-
const confirmFormRef = ref();
45-
const disableFormRef = ref();
46-
const regenerateFormRef = ref();
47-
48-
const handleEnableSuccess = (): void => {
42+
const showTwoFactorEnableModal = (): void => {
4943
showModal.value = true;
5044
};
5145
52-
const handleEnableError = (): void => {
53-
error.value = 'Failed to enable two-factor authentication';
54-
};
55-
56-
const handleEnableFinish = (): void => {
57-
// Form component handles loading state automatically
58-
};
59-
6046
const handleConfirmSuccess = (): void => {
6147
verifyStep.value = false;
6248
showModal.value = false;
@@ -70,14 +56,6 @@ const handleConfirmError = (errors: any): void => {
7056
pinValue.value = [];
7157
};
7258
73-
const handleDisableSuccess = (): void => {
74-
showingRecoveryCodes.value = false;
75-
};
76-
77-
const handleDisableError = (): void => {
78-
console.error('Error disabling 2FA');
79-
};
80-
8159
const copyToClipboard = (text: string): void => {
8260
navigator.clipboard.writeText(text).then(() => (copied.value = true));
8361
setTimeout(() => (copied.value = false), 1500);
@@ -100,20 +78,17 @@ const toggleRecoveryCodes = () => {
10078
<HeadingSmall title="Two-Factor Authentication" description="Manage your two-factor authentication settings" />
10179
<div v-if="!props.confirmed" class="flex flex-col items-start justify-start space-y-5">
10280
<Badge variant="outline" class="border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-50"> Disabled </Badge>
103-
<p class="-translate-y-1 text-stone-500 dark:text-stone-400">
81+
<p class="-translate-y-1 text-muted-foreground">
10482
When you enable 2FA, you'll be prompted for a secure code during login, which can be retrieved from your phone's TOTP
10583
supported app.
10684
</p>
10785
<Dialog :open="showModal" @update:open="showModal = $event">
10886
<DialogTrigger as-child>
10987
<Form
110-
ref="enableFormRef"
11188
:async="false"
11289
:action="route('two-factor.enable')"
11390
method="post"
114-
@success="handleEnableSuccess"
115-
@error="handleEnableError"
116-
@finish="handleEnableFinish"
91+
@success="showTwoFactorEnableModal"
11792
#default="{ processing }"
11893
>
11994
<Button type="submit" :disabled="processing">
@@ -123,15 +98,19 @@ const toggleRecoveryCodes = () => {
12398
</DialogTrigger>
12499
<DialogContent class="sm:max-w-md">
125100
<DialogHeader class="flex items-center justify-center">
126-
<div class="mb-3 w-auto rounded-full border border-stone-100 bg-white p-0.5 shadow-sm dark:border-stone-600 dark:bg-stone-800">
127-
<div class="relative overflow-hidden rounded-full border border-stone-200 bg-stone-100 p-2.5 dark:border-stone-600 dark:bg-stone-200">
128-
<div class="absolute inset-0 flex h-full w-full items-stretch justify-around divide-x divide-stone-200 opacity-50 dark:divide-stone-300 [&>div]:flex-1">
101+
<div class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm">
102+
<div class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
103+
<div
104+
class="absolute inset-0 flex size-full items-stretch justify-around divide-x divide-border opacity-50 [&>div]:flex-1"
105+
>
129106
<div v-for="i in 5" :key="i"></div>
130107
</div>
131-
<div class="absolute inset-0 flex h-full w-full flex-col items-stretch justify-around divide-y divide-stone-200 opacity-50 dark:divide-stone-300 [&>div]:flex-1">
108+
<div
109+
class="absolute inset-0 flex size-full flex-col items-stretch justify-around divide-y divide-border opacity-50 [&>div]:flex-1"
110+
>
132111
<div v-for="i in 5" :key="i"></div>
133112
</div>
134-
<ScanLine class="relative z-20 size-6 dark:text-black" />
113+
<ScanLine class="relative z-20 size-6 text-foreground" />
135114
</div>
136115
</div>
137116
<DialogTitle>
@@ -149,19 +128,17 @@ const toggleRecoveryCodes = () => {
149128
<div class="relative flex w-auto flex-col items-center justify-center space-y-5">
150129
<template v-if="!verifyStep">
151130
<div class="relative mx-auto flex max-w-md items-center overflow-hidden">
152-
<div
153-
class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-stone-200 dark:border-stone-700"
154-
>
131+
<div class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border">
155132
<div
156133
v-if="!props.qrCodeSvg"
157-
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-white dark:bg-stone-700"
134+
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
158135
>
159136
<Loader2 class="size-6 animate-spin" />
160137
</div>
161138
<div v-else class="relative z-10 overflow-hidden border p-5">
162139
<div
163140
v-html="props.qrCodeSvg"
164-
class="flex aspect-square h-full w-full items-center justify-center"
141+
class="flex aspect-square size-full items-center justify-center"
165142
></div>
166143
<div v-if="props.qrCodeSvg" class="animate-scanning-line absolute inset-0 h-full w-full">
167144
<div
@@ -192,28 +169,25 @@ const toggleRecoveryCodes = () => {
192169
</div>
193170

194171
<div class="relative flex w-full items-center justify-center">
195-
<div class="absolute inset-0 top-1/2 h-px w-full bg-stone-200 dark:bg-stone-600"></div>
196-
<span class="relative bg-white px-2 py-1 dark:bg-stone-800"> or, enter the code manually </span>
172+
<div class="absolute inset-0 top-1/2 h-px w-full bg-border"></div>
173+
<span class="relative bg-card px-2 py-1"> or, enter the code manually </span>
197174
</div>
198175

199176
<div class="flex w-full items-center justify-center space-x-2">
200-
<div class="flex w-full items-stretch overflow-hidden rounded-xl border dark:border-stone-700">
201-
<div
202-
v-if="!props.secretKey"
203-
class="flex h-full w-full items-center justify-center bg-stone-100 p-3 dark:bg-stone-700"
204-
>
177+
<div class="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
178+
<div v-if="!props.secretKey" class="flex h-full w-full items-center justify-center bg-muted p-3">
205179
<Loader2 class="size-4 animate-spin" />
206180
</div>
207181
<template v-else>
208182
<input
209183
type="text"
210184
readonly
211185
:value="props.secretKey"
212-
class="h-full w-full bg-white p-3 text-black dark:bg-stone-800 dark:text-stone-100"
186+
class="h-full w-full bg-background p-3 text-foreground"
213187
/>
214188
<button
215189
@click="copyToClipboard(props.secretKey || '')"
216-
class="relative block h-auto border-l border-stone-200 px-3 hover:bg-stone-100 dark:border-stone-600 dark:hover:bg-stone-600"
190+
class="relative block h-auto border-l border-border px-3 hover:bg-muted"
217191
>
218192
<Check v-if="copied" class="w-4 text-green-500" />
219193
<Copy v-else class="w-4" />
@@ -225,9 +199,9 @@ const toggleRecoveryCodes = () => {
225199

226200
<template v-else>
227201
<Form
228-
ref="confirmFormRef"
229202
:action="route('two-factor.confirm')"
230203
method="post"
204+
:async="false"
231205
@success="handleConfirmSuccess"
232206
@error="handleConfirmError"
233207
#default="{ processing }"
@@ -279,23 +253,23 @@ const toggleRecoveryCodes = () => {
279253

280254
<div v-if="props.confirmed" class="flex flex-col items-start justify-start space-y-5">
281255
<Badge variant="outline" class="border-green-200 bg-green-50 text-green-700 hover:bg-green-50"> Enabled </Badge>
282-
<p class="text-stone-500 dark:text-stone-400">
256+
<p class="text-muted-foreground">
283257
With two factor authentication enabled, you'll be prompted for a secure, random token during login, which you can retrieve
284258
from your TOTP Authenticator app.
285259
</p>
286260

287261
<div>
288-
<div class="flex items-start rounded-t-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-700 dark:bg-stone-800">
262+
<div class="flex items-start rounded-t-xl border border-muted p-4">
289263
<LockKeyhole class="mr-2 size-5 text-stone-500" />
290264
<div class="space-y-1">
291265
<h3 class="font-medium">2FA Recovery Codes</h3>
292-
<p class="text-sm text-stone-500 dark:text-stone-400">
266+
<p class="text-sm text-muted-foreground">
293267
Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.
294268
</p>
295269
</div>
296270
</div>
297271

298-
<div class="rounded-b-xl border border-t-0 border-stone-200 bg-stone-100 text-sm dark:border-stone-700 dark:bg-stone-800">
272+
<div class="rounded-b-xl border border-t-0 border-stone-200 bg-secondary dark:border-stone-700 text-sm">
299273
<div
300274
@click="toggleRecoveryCodes"
301275
class="group flex h-10 cursor-pointer items-center justify-between px-5 text-xs select-none"
@@ -311,12 +285,14 @@ const toggleRecoveryCodes = () => {
311285

312286
<Form
313287
v-if="showingRecoveryCodes"
314-
ref="regenerateFormRef"
315288
:action="route('two-factor.recovery-codes')"
316289
method="post"
317290
#default="{ processing }"
291+
:options="{
292+
preserveScroll: true,
293+
}"
318294
>
319-
<Button size="sm" variant="outline" class="text-stone-600" type="submit" :disabled="processing" @click.stop>
295+
<Button size="sm" variant="ghost" class="text-underline" type="submit" :disabled="processing" @click.stop>
320296
{{ processing ? 'Regenerating...' : 'Regenerate Codes' }}
321297
</Button>
322298
</Form>
@@ -329,10 +305,10 @@ const toggleRecoveryCodes = () => {
329305
opacity: showingRecoveryCodes ? 1 : 0,
330306
}"
331307
>
332-
<div class="grid max-w-xl gap-1 bg-stone-200 px-4 py-4 font-mono text-sm dark:bg-stone-900 dark:text-stone-100">
308+
<div class="grid max-w-xl gap-1 bg-secondary p-4 font-mono text-sm">
333309
<div v-for="(code, index) in props.recoveryCodes" :key="index">{{ code }}</div>
334310
</div>
335-
<p class="px-4 py-3 text-xs text-stone-500 select-none dark:text-stone-400">
311+
<p class="px-4 py-3 text-xs text-muted-foreground select-none">
336312
You have {{ props.recoveryCodes.length }} recovery codes left. Each can be used once to access your account and
337313
will be removed after use. If you need more, click <span class="font-bold">Regenerate Codes</span> above.
338314
</p>
@@ -342,11 +318,9 @@ const toggleRecoveryCodes = () => {
342318

343319
<div class="relative inline">
344320
<Form
345-
ref="disableFormRef"
346321
:action="route('two-factor.disable')"
347322
method="delete"
348-
@success="handleDisableSuccess"
349-
@error="handleDisableError"
323+
async="true"
350324
#default="{ processing }"
351325
>
352326
<Button variant="destructive" type="submit" :disabled="processing">

0 commit comments

Comments
 (0)