@stacksjs/defaults 0.72.49 → 0.72.54

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.
Files changed (33) hide show
  1. package/ai/skills/stacks-browse/scripts/browse.ts +63 -4
  2. package/ide/vscode/package.json +1 -1
  3. package/lang/de.yml +0 -1
  4. package/package.json +7 -2
  5. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +8 -0
  6. package/resources/components/Dashboard/Auth/Login.stx +8 -0
  7. package/resources/components/Dashboard/Auth/LoginDashboard.stx +5 -1
  8. package/resources/components/Dashboard/Auth/MagicLink.stx +94 -0
  9. package/resources/components/Dashboard/Auth/Register.stx +7 -0
  10. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +5 -1
  11. package/resources/components/Dashboard/Auth/ResetPassword.stx +163 -0
  12. package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +95 -0
  13. package/resources/layouts/403.stx +15 -148
  14. package/resources/layouts/404.stx +7 -2
  15. package/resources/views/auth/magic/[token].stx +24 -0
  16. package/resources/views/errors/404.stx +42 -0
  17. package/resources/views/forgot-password.stx +13 -0
  18. package/resources/views/login.stx +68 -0
  19. package/resources/views/password/reset/[token].stx +18 -0
  20. package/resources/views/register.stx +64 -0
  21. package/vcs/github/workflows/ci.yml +87 -0
  22. package/views/[...all].stx +0 -7
  23. package/views/about.md +0 -3
  24. package/views/auth/login.stx +0 -1
  25. package/views/auth/magic/[token].stx +0 -88
  26. package/views/auth/signup.stx +0 -120
  27. package/views/forum/category.stx +0 -92
  28. package/views/forum/index.stx +0 -209
  29. package/views/forum/new-topic.stx +0 -113
  30. package/views/forum/profile.stx +0 -1
  31. package/views/forum/topic.stx +0 -143
  32. package/views/hello-world/[name].stx +0 -47
  33. package/views/index.stx +0 -21
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Commands:
10
10
  * bun browse.ts navigate <url>
11
- * bun browse.ts screenshot <url> [--viewport WxH] [--full] [--element SEL] [--scale N] [--out PATH]
11
+ * bun browse.ts screenshot <url> [--viewport WxH] [--full | --element SEL | --scroll-y N] [--scale N] [--out PATH]
12
12
  * bun browse.ts responsive <url> [--out-dir DIR]
13
13
  * bun browse.ts monitor <url> [--ms 5000]
14
14
  * bun browse.ts snapshot <url>
@@ -403,7 +403,39 @@ async function title(cdp: Cdp): Promise<string> {
403
403
  return r.result?.value ?? ''
404
404
  }
405
405
 
406
- async function captureScreenshot(cdp: Cdp, opts: { full?: boolean, element?: string } = {}): Promise<Buffer> {
406
+ /**
407
+ * Scroll the real page to `y` and let one frame settle before the caller
408
+ * screenshots it.
409
+ *
410
+ * `--full` captures the whole document through `captureBeyondViewport`,
411
+ * which paints content at its full-page layout position without ever
412
+ * moving `scrollY` - correct for a top-to-bottom review, but useless for
413
+ * checking anything that depends on an actual mid-scroll viewport: a
414
+ * `position: sticky` nav overlapping content beneath it, a CSS
415
+ * `animation-timeline: view()` reveal mid-transition, or simply "what does
416
+ * the fold look like 800px down". `window.scrollTo(..., { behavior:
417
+ * 'instant' })` forces the jump synchronously even when the page sets
418
+ * `scroll-behavior: smooth`, which would otherwise animate the scroll and
419
+ * leave a screenshot taken immediately after mid-flight. The two rAF
420
+ * round-trips that follow give the compositor one full frame to repaint
421
+ * sticky/fixed layers and any scroll-driven animation at the new offset
422
+ * before `Page.captureScreenshot` reads back pixels, which
423
+ * `Runtime.evaluate` returning does not by itself guarantee.
424
+ */
425
+ async function scrollTo(cdp: Cdp, y: number): Promise<void> {
426
+ const r = await cdp.send('Runtime.evaluate', {
427
+ expression: `(() => {
428
+ window.scrollTo({ top: ${y}, left: 0, behavior: 'instant' })
429
+ return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(window.scrollY))))
430
+ })()`,
431
+ returnByValue: true,
432
+ awaitPromise: true,
433
+ })
434
+ if (r.exceptionDetails)
435
+ throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'scrollTo failed')
436
+ }
437
+
438
+ async function captureScreenshot(cdp: Cdp, opts: { full?: boolean, element?: string, scrollY?: number } = {}): Promise<Buffer> {
407
439
  let clip: any
408
440
  let captureBeyondViewport = false
409
441
 
@@ -423,6 +455,9 @@ async function captureScreenshot(cdp: Cdp, opts: { full?: boolean, element?: str
423
455
  clip = { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height), scale: 1 }
424
456
  captureBeyondViewport = true
425
457
  }
458
+ else if (opts.scrollY != null) {
459
+ await scrollTo(cdp, opts.scrollY)
460
+ }
426
461
 
427
462
  const r = await cdp.send('Page.captureScreenshot', { format: 'png', ...(clip ? { clip, captureBeyondViewport } : {}) })
428
463
  return Buffer.from(r.data, 'base64')
@@ -523,6 +558,22 @@ function parseViewport(value: string | undefined): { w: number, h: number } {
523
558
  return { w, h }
524
559
  }
525
560
 
561
+ /** `--scroll-y` on `screenshot`: an explicit document Y to jump to before
562
+ * capturing just the viewport, for content a full-page capture cannot show
563
+ * (sticky/fixed elements, scroll-linked animation) at a specific fold. */
564
+ function parseScrollY(value: string | boolean | Array<string | boolean> | undefined): number | null {
565
+ if (value === undefined)
566
+ return null
567
+ if (typeof value !== 'string')
568
+ throw new TypeError('--scroll-y requires a value, for example --scroll-y 800.')
569
+
570
+ const y = Number(value)
571
+ if (!Number.isFinite(y) || y < 0)
572
+ throw new TypeError(`Invalid --scroll-y "${value}". Expected a non-negative number of pixels.`)
573
+
574
+ return y
575
+ }
576
+
526
577
  type ScenarioAction = 'assert' | 'click' | 'evaluate' | 'fill' | 'focus' | 'press' | 'wait'
527
578
 
528
579
  interface ScenarioStep {
@@ -703,6 +754,10 @@ async function main() {
703
754
  console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
704
755
  console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
705
756
  console.log(' --scheme dark emulate prefers-color-scheme (light|dark) for QA of theme-aware pages')
757
+ console.log(' --scroll-y 800 screenshot: jump to this document Y first, then capture just the viewport')
758
+ console.log(' (--full renders beyond-viewport without ever scrolling, so it cannot show a')
759
+ console.log(' position:sticky/fixed element overlapping content, or a scroll-linked')
760
+ console.log(' animation mid-transition, at a specific fold - --scroll-y can)')
706
761
  process.exit(url ? 0 : 1)
707
762
  }
708
763
 
@@ -733,12 +788,16 @@ async function main() {
733
788
  const cdp = await openPage(session.port)
734
789
  const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
735
790
  const scale = flags.scale ? Number(flags.scale) : 1
791
+ const scrollY = parseScrollY(flags['scroll-y'])
792
+ const modes = [flags.full && 'full', flags.element && 'element', scrollY != null && 'scroll-y'].filter(Boolean)
793
+ if (modes.length > 1)
794
+ throw new TypeError(`--full, --element, and --scroll-y are mutually exclusive; got ${modes.join(', ')}.`)
736
795
  const out = (flags.out as string) || `storage/framework/runtime/shots/${new URL(url).pathname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}.png`
737
796
  mkdirSync(out.split('/').slice(0, -1).join('/') || '.', { recursive: true })
738
797
  const state = await gotoAndInstrument(cdp, url, { viewport, scale, cookies, settleMs, scheme })
739
- const png = await captureScreenshot(cdp, { full: !!flags.full, element: flags.element as string | undefined })
798
+ const png = await captureScreenshot(cdp, { full: !!flags.full, element: flags.element as string | undefined, scrollY: scrollY ?? undefined })
740
799
  await Bun.write(out, png)
741
- console.log(JSON.stringify({ url, out, viewport: `${viewport.w}x${viewport.h}`, scale, full: !!flags.full, element: flags.element ?? null, bytes: png.length }, null, 2))
800
+ console.log(JSON.stringify({ url, out, viewport: `${viewport.w}x${viewport.h}`, scale, full: !!flags.full, element: flags.element ?? null, scrollY: scrollY ?? null, bytes: png.length }, null, 2))
742
801
  state.dispose()
743
802
  cdp.close()
744
803
  }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.72.49",
5
+ "version": "0.72.54",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/lang/de.yml CHANGED
@@ -11,4 +11,3 @@ intro:
11
11
  hi: Hi, {name}!
12
12
  aka: Auch bekannt als
13
13
  whats-your-name: Wie heißt du?
14
- not-found: Nicht gefunden
package/package.json CHANGED
@@ -2,7 +2,12 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.49",
5
+ "version": "0.72.54",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/stacksjs/stacks.git",
9
+ "directory": "./storage/framework/core/defaults"
10
+ },
6
11
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
12
  "author": "Chris Breuer",
8
13
  "license": "MIT",
@@ -46,7 +51,7 @@
46
51
  "dependencies": {
47
52
  "@iconify-json/f7": "^1.2.2",
48
53
  "@iconify-json/hugeicons": "^1.2.27",
49
- "@stacksjs/mobile": "^0.72.49",
54
+ "@stacksjs/mobile": "^0.72.54",
50
55
  "@stacksjs/sanitizer": "^0.2.113"
51
56
  },
52
57
  "scripts": {
@@ -1,4 +1,12 @@
1
1
  <script client>
2
+ // Imported rather than taken as a global, the way LoginDashboard and
3
+ // RegisterDashboard already import useAuth. The bare name only resolves on the
4
+ // dashboard server, so mounting this container anywhere else - such as the
5
+ // site's /forgot-password page - reached the submit handler and threw
6
+ // "dashboardApi is not defined", which the catch below then showed to the
7
+ // visitor as the reason their reset link could not be sent.
8
+ import { dashboardApi } from '../../../../functions/dashboard-api'
9
+
2
10
  const isLoading = state(false)
3
11
  const error = state('')
4
12
  const success = state(false)
@@ -129,6 +129,14 @@ function handleSubmit(): void {
129
129
  <span :if="!isLoading()">Sign in</span>
130
130
  </Button>
131
131
  </form>
132
+
133
+ {{--
134
+ Anything that belongs with the credentials, inside the same card:
135
+ the social sign-in buttons the /login page fills this with. It is a
136
+ slot because the provider list has to be computed server-side by
137
+ `configuredSocialProviders()`, and this component is client-only.
138
+ --}}
139
+ <slot name="social" />
132
140
  </div>
133
141
 
134
142
  <p :if="showSignUp()" class="mt-6 text-center text-neutral-500 text-sm dark:text-neutral-400">
@@ -41,5 +41,9 @@ async function handleLogin(credentials) {
41
41
  :isLoading="isLoading()"
42
42
  :error="error()"
43
43
  :data-auth-status="isLoading() ? 'loading' : 'ready'"
44
- />
44
+ >
45
+ <!-- Forwarded so the page mounting this container can put social
46
+ sign-in inside the card. -->
47
+ <template #social><slot name="social" /></template>
48
+ </Login>
45
49
  </template>
@@ -0,0 +1,94 @@
1
+ <script client>
2
+ /**
3
+ * The magic-link interstitial.
4
+ *
5
+ * The email's GET lands on the page that mounts this, and this posts the token
6
+ * to the consume endpoint - a GET must never consume, because mail-security
7
+ * scanners prefetch links and would burn the single-use token before the
8
+ * person ever taps it. When `autoConsume` is off it waits for a tap instead,
9
+ * for environments whose scanners execute page scripts too.
10
+ *
11
+ * `autoConsume` arrives as a string because it is decided server-side, from
12
+ * `config.auth.magicLink.confirmInteraction`, and a client script cannot read
13
+ * config directly.
14
+ */
15
+ const autoConsume = useReactiveProp<string>('autoConsume', 'true')
16
+ const route = useRoute()
17
+ const status = state('ready') // ready | working | done | failed
18
+ const message = state('')
19
+
20
+ async function consume() {
21
+ if (status() === 'working' || status() === 'done')
22
+ return
23
+
24
+ status.set('working')
25
+ try {
26
+ const reply = await fetch('/auth/magic-link/consume', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ credentials: 'same-origin',
30
+ body: JSON.stringify({ token: String(route.params.token || '') }),
31
+ })
32
+ const body = await reply.json().catch(() => ({}))
33
+
34
+ if (!reply.ok) {
35
+ status.set('failed')
36
+ message.set(body.message || body.error || 'That sign-in link is not valid.')
37
+ return
38
+ }
39
+
40
+ status.set('done')
41
+ const redirect = String(body.redirect_to || '/')
42
+ navigate(redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/')
43
+ }
44
+ catch {
45
+ status.set('failed')
46
+ message.set('Something went wrong. Request a new link and try again.')
47
+ }
48
+ }
49
+
50
+ // `consume` reads `status()`, so calling it from inside the effect makes the
51
+ // effect depend on `status` - and every set inside `consume` schedules another
52
+ // run. Its own early return covers 'working' and 'done', but not 'failed', so a
53
+ // link that fails to consume would be retried forever. A plain flag, which is
54
+ // not a signal and so not a dependency, is what makes this fire once.
55
+ let autoStarted = false
56
+
57
+ effect(() => {
58
+ if (autoStarted || autoConsume() !== 'true')
59
+ return
60
+
61
+ autoStarted = true
62
+ consume()
63
+ })
64
+ </script>
65
+
66
+ <template>
67
+ <main class="flex items-center justify-center px-6 min-h-[100dvh]">
68
+ <div class="max-w-sm w-full text-center">
69
+ <div :if="status() === 'failed'">
70
+ <div class="flex items-center justify-center mx-auto h-12 w-12 bg-red-50 rounded-full">
71
+ <span class="h-6 w-6 text-red-500 i-hugeicons-alert-02"></span>
72
+ </div>
73
+ <h1 class="mt-5 font-semibold text-xl tracking-tight">Sign-in link problem</h1>
74
+ <p class="mt-2 text-[15px] text-gray-600">{{ message() }}</p>
75
+ <a href="/login" class="inline-block mt-6 px-5 py-2.5 font-medium text-white bg-gray-900 rounded-lg">Back to sign in</a>
76
+ </div>
77
+ <div :else-if="status() === 'working' || status() === 'done'">
78
+ <div class="flex items-center justify-center mx-auto h-12 w-12 bg-gray-100 rounded-full">
79
+ <span class="h-6 w-6 text-gray-500 animate-spin i-hugeicons-loading-03"></span>
80
+ </div>
81
+ <h1 class="mt-5 font-semibold text-xl tracking-tight">Signing you in</h1>
82
+ <p class="mt-2 text-[15px] text-gray-600">One moment.</p>
83
+ </div>
84
+ <div :else>
85
+ <div class="flex items-center justify-center mx-auto h-12 w-12 bg-gray-100 rounded-full">
86
+ <span class="h-6 w-6 text-gray-600 i-hugeicons-mail-open-01"></span>
87
+ </div>
88
+ <h1 class="mt-5 font-semibold text-xl tracking-tight">Continue to sign in</h1>
89
+ <p class="mt-2 text-[15px] text-gray-600">Tap below to finish signing in with your emailed link.</p>
90
+ <button type="button" @click="consume" class="mt-6 px-6 py-3 font-medium text-white bg-gray-900 rounded-lg transition-transform active:scale-[0.98]">Continue</button>
91
+ </div>
92
+ </div>
93
+ </main>
94
+ </template>
@@ -225,6 +225,13 @@ function handleSubmit(): void {
225
225
  </Button>
226
226
  </form>
227
227
 
228
+ {{--
229
+ Anything that belongs with the credentials, inside the same card:
230
+ the social sign-in buttons the /register page fills this with. It is
231
+ a slot because the provider list has to be computed server-side by
232
+ `configuredSocialProviders()`, and this component is client-only.
233
+ --}}
234
+ <slot name="social" />
228
235
  </div>
229
236
 
230
237
  <!-- Login Link -->
@@ -41,5 +41,9 @@ async function handleRegister(credentials) {
41
41
  :isLoading="isLoading()"
42
42
  :error="error()"
43
43
  :data-auth-status="isLoading() ? 'loading' : 'ready'"
44
- />
44
+ >
45
+ <!-- Forwarded so the page mounting this container can put social
46
+ sign-in inside the card. -->
47
+ <template #social><slot name="social" /></template>
48
+ </Register>
45
49
  </template>
@@ -0,0 +1,163 @@
1
+ @import('../UI/Input', '../UI/Button')
2
+
3
+ {{--
4
+ The page a mailed reset link lands on. Presentational only: it renders the
5
+ four states the flow has and emits `submit`; ResetPasswordDashboard decides
6
+ which state applies and talks to the API.
7
+
8
+ States, in the order a visitor meets them:
9
+ checking - the token is being verified, before any field is shown
10
+ invalid - the token is expired, already used, or was never real
11
+ success - the password is changed
12
+ (default) - the form
13
+ --}}
14
+
15
+ <script client>
16
+ const emit = defineEmits()
17
+ const title = useReactiveProp<string>('title', 'Choose a new password')
18
+ const subtitle = useReactiveProp<string>('subtitle', 'Set the password you will use to sign in')
19
+ const isLoading = useReactiveProp<boolean>('isLoading', false)
20
+ const error = useReactiveProp<string>('error', '')
21
+ const success = useReactiveProp<boolean>('success', false)
22
+ const checking = useReactiveProp<boolean>('checking', false)
23
+ const invalid = useReactiveProp<boolean>('invalid', false)
24
+ const password = state('')
25
+ const confirmation = state('')
26
+
27
+ function mismatch(): boolean {
28
+ return confirmation().length > 0 && password() !== confirmation()
29
+ }
30
+
31
+ function canSubmit(): boolean {
32
+ return !isLoading() && password().length > 0 && confirmation().length > 0 && !mismatch()
33
+ }
34
+
35
+ function handleSubmit(): void {
36
+ if (!canSubmit())
37
+ return
38
+
39
+ emit('submit', {
40
+ password: password(),
41
+ password_confirmation: confirmation(),
42
+ })
43
+ }
44
+ </script>
45
+
46
+ <div class="flex items-center justify-center px-4 py-12 min-h-screen">
47
+ <div class="max-w-sm w-full">
48
+ <!-- Logo/Brand -->
49
+ <div class="mb-8 text-center">
50
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-blue-500/25 shadow-lg">
51
+ <span class="h-7 w-7 text-white i-hugeicons-cube"></span>
52
+ </div>
53
+ <h1 class="font-semibold text-2xl text-neutral-900 dark:text-white">
54
+ {{ title() }}
55
+ </h1>
56
+ <p class="mt-2 text-neutral-500 text-sm dark:text-neutral-400">
57
+ {{ subtitle() }}
58
+ </p>
59
+ </div>
60
+
61
+ <!-- Form Card -->
62
+ <div class="p-6 bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800 shadow-black/5 shadow-xl dark:shadow-black/20">
63
+ <div :if="checking()">
64
+ <div class="py-4 text-center">
65
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-neutral-100 dark:bg-neutral-800 rounded-full">
66
+ <div class="h-6 w-6 text-neutral-500 animate-spin i-hugeicons-loading-03"></div>
67
+ </div>
68
+ <p class="text-neutral-500 text-sm dark:text-neutral-400">Checking your link</p>
69
+ </div>
70
+ </div>
71
+
72
+ <div :else-if="invalid()">
73
+ <div class="py-4 text-center">
74
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-red-100 dark:bg-red-900/30 rounded-full">
75
+ <div class="h-6 w-6 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
76
+ </div>
77
+ <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
78
+ This link no longer works
79
+ </h2>
80
+ <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
81
+ Reset links expire, and each one can be used only once. Ask for a fresh link and it will arrive in a moment.
82
+ </p>
83
+ <Button href="/forgot-password">
84
+ Send a new link
85
+ </Button>
86
+ </div>
87
+ </div>
88
+
89
+ <div :else-if="success()">
90
+ <div class="py-4 text-center">
91
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-green-100 dark:bg-green-900/30 rounded-full">
92
+ <div class="h-6 w-6 text-green-600 dark:text-green-400 i-hugeicons-checkmark-circle-02"></div>
93
+ </div>
94
+ <h2 class="mb-2 font-medium text-lg text-neutral-900 dark:text-white">
95
+ Password changed
96
+ </h2>
97
+ <p class="mb-6 text-neutral-500 text-sm dark:text-neutral-400">
98
+ You can sign in with your new password now.
99
+ </p>
100
+ <Button href="/login">
101
+ Go to sign in
102
+ </Button>
103
+ </div>
104
+ </div>
105
+
106
+ <div :else>
107
+ <div :if="error()" role="alert" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-lg dark:border-red-800">
108
+ <div class="flex gap-2 items-center">
109
+ <div class="h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-alert-circle"></div>
110
+ <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
111
+ </div>
112
+ </div>
113
+
114
+ <form @submit.prevent="handleSubmit">
115
+ <div class="mb-4">
116
+ <Input
117
+ type="password"
118
+ label="New password"
119
+ placeholder="At least 8 characters"
120
+ autocomplete="new-password"
121
+ v-model:value="password"
122
+ required
123
+ :disabled="isLoading()"
124
+ />
125
+ </div>
126
+
127
+ <div class="mb-6">
128
+ <Input
129
+ type="password"
130
+ label="Confirm new password"
131
+ placeholder="Repeat your new password"
132
+ autocomplete="new-password"
133
+ v-model:value="confirmation"
134
+ :error="mismatch() ? 'Both passwords must match' : ''"
135
+ required
136
+ :disabled="isLoading()"
137
+ />
138
+ </div>
139
+
140
+ <Button
141
+ type="submit"
142
+ variant="primary"
143
+ size="lg"
144
+ fullWidth
145
+ :loading="isLoading()"
146
+ :disabled="!canSubmit()"
147
+ >
148
+ <span :if="isLoading()">Saving...</span>
149
+ <span :if="!isLoading()">Change password</span>
150
+ </Button>
151
+ </form>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Back to Login -->
156
+ <p class="mt-6 text-center text-neutral-500 text-sm dark:text-neutral-400">
157
+ <a href="/login" class="inline-flex gap-1 items-center font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
158
+ <div class="h-4 w-4 i-hugeicons-arrow-left-01"></div>
159
+ Back to sign in
160
+ </a>
161
+ </p>
162
+ </div>
163
+ </div>
@@ -0,0 +1,95 @@
1
+ <script client>
2
+ /**
3
+ * Container for the mailed reset link.
4
+ *
5
+ * The link the framework sends is `/password/reset/{token}?email={email}`
6
+ * (core/auth/src/password/reset.ts, overridable via
7
+ * `config.auth.passwordReset.url`), so both halves of the credential come off
8
+ * the URL: the token from the route, the address from the query string.
9
+ *
10
+ * The token is verified before any field is shown. `/password/verify-token`
11
+ * exists for exactly this, and it means an expired link says so immediately
12
+ * instead of after someone has chosen and typed a new password twice.
13
+ */
14
+ import { dashboardApi } from '../../../../functions/dashboard-api'
15
+
16
+ const route = useRoute()
17
+
18
+ const checking = state(true)
19
+ const invalid = state(false)
20
+ const isLoading = state(false)
21
+ const error = state('')
22
+ const success = state(false)
23
+
24
+ function token(): string {
25
+ return String(route.params.token || '')
26
+ }
27
+
28
+ function email(): string {
29
+ return String(route.query.email || '')
30
+ }
31
+
32
+ async function verify(): Promise<void> {
33
+ if (!token() || !email()) {
34
+ checking.set(false)
35
+ invalid.set(true)
36
+ return
37
+ }
38
+
39
+ try {
40
+ await dashboardApi('/password/verify-token', {
41
+ method: 'POST',
42
+ auth: false,
43
+ body: { token: token(), email: email() },
44
+ })
45
+ }
46
+ catch {
47
+ // Any failure here is the link, not the network: a token that verifies
48
+ // is the only thing that lets the form be worth showing.
49
+ invalid.set(true)
50
+ }
51
+ finally {
52
+ checking.set(false)
53
+ }
54
+ }
55
+
56
+ async function handleReset(data): Promise<void> {
57
+ isLoading.set(true)
58
+ error.set('')
59
+
60
+ try {
61
+ await dashboardApi('/password/reset', {
62
+ method: 'POST',
63
+ auth: false,
64
+ body: {
65
+ token: token(),
66
+ email: email(),
67
+ password: data.password,
68
+ password_confirmation: data.password_confirmation,
69
+ },
70
+ })
71
+ success.set(true)
72
+ }
73
+ catch (err) {
74
+ error.set(err instanceof Error ? err.message : 'Could not change your password')
75
+ }
76
+ finally {
77
+ isLoading.set(false)
78
+ }
79
+ }
80
+
81
+ effect(() => {
82
+ verify()
83
+ })
84
+ </script>
85
+
86
+ <template>
87
+ <ResetPassword
88
+ @submit="handleReset"
89
+ :checking="checking()"
90
+ :invalid="invalid()"
91
+ :isLoading="isLoading()"
92
+ :error="error()"
93
+ :success="success()"
94
+ />
95
+ </template>