@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.
- package/ai/skills/stacks-browse/scripts/browse.ts +63 -4
- package/ide/vscode/package.json +1 -1
- package/lang/de.yml +0 -1
- package/package.json +7 -2
- package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +8 -0
- package/resources/components/Dashboard/Auth/Login.stx +8 -0
- package/resources/components/Dashboard/Auth/LoginDashboard.stx +5 -1
- package/resources/components/Dashboard/Auth/MagicLink.stx +94 -0
- package/resources/components/Dashboard/Auth/Register.stx +7 -0
- package/resources/components/Dashboard/Auth/RegisterDashboard.stx +5 -1
- package/resources/components/Dashboard/Auth/ResetPassword.stx +163 -0
- package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +95 -0
- package/resources/layouts/403.stx +15 -148
- package/resources/layouts/404.stx +7 -2
- package/resources/views/auth/magic/[token].stx +24 -0
- package/resources/views/errors/404.stx +42 -0
- package/resources/views/forgot-password.stx +13 -0
- package/resources/views/login.stx +68 -0
- package/resources/views/password/reset/[token].stx +18 -0
- package/resources/views/register.stx +64 -0
- package/vcs/github/workflows/ci.yml +87 -0
- package/views/[...all].stx +0 -7
- package/views/about.md +0 -3
- package/views/auth/login.stx +0 -1
- package/views/auth/magic/[token].stx +0 -88
- package/views/auth/signup.stx +0 -120
- package/views/forum/category.stx +0 -92
- package/views/forum/index.stx +0 -209
- package/views/forum/new-topic.stx +0 -113
- package/views/forum/profile.stx +0 -1
- package/views/forum/topic.stx +0 -143
- package/views/hello-world/[name].stx +0 -47
- 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
|
|
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
|
-
|
|
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
|
}
|
package/ide/vscode/package.json
CHANGED
package/lang/de.yml
CHANGED
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.
|
|
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.
|
|
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>
|