@bemaestro/saas-ui 0.0.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/LICENSE +193 -0
- package/README.md +43 -0
- package/package.json +43 -0
- package/src/SaasShell.vue +741 -0
- package/src/auth/AcceptInvitationPanel.vue +76 -0
- package/src/auth/AuthCard.vue +14 -0
- package/src/auth/CreateOrgPanel.vue +70 -0
- package/src/auth/ForgotPasswordForm.vue +55 -0
- package/src/auth/ResetPasswordForm.vue +93 -0
- package/src/auth/SelectOrgPanel.vue +65 -0
- package/src/auth/SignInForm.vue +88 -0
- package/src/auth/SignUpForm.vue +102 -0
- package/src/auth/VerifyEmailPanel.vue +48 -0
- package/src/auth/auth-route.ts +113 -0
- package/src/auth/use-anon-client.ts +32 -0
- package/src/cookies.ts +28 -0
- package/src/index.ts +19 -0
- package/src/session.ts +104 -0
- package/src/styles.css +187 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue'
|
|
3
|
+
import type { MaestroClient } from '@bemaestro/sdk/runtime'
|
|
4
|
+
import { errorMessage } from './use-anon-client.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
token: string | null
|
|
8
|
+
authenticated: boolean
|
|
9
|
+
client: MaestroClient | null
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
navigate: [screen: 'sign-in' | 'sign-up', redirect?: string]
|
|
14
|
+
accepted: [orgId: string]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
type State = 'idle' | 'accepting' | 'success' | 'error'
|
|
18
|
+
const state = ref<State>('idle')
|
|
19
|
+
const errorText = ref('')
|
|
20
|
+
|
|
21
|
+
async function accept() {
|
|
22
|
+
if (!props.token || !props.client) return
|
|
23
|
+
state.value = 'accepting'
|
|
24
|
+
errorText.value = ''
|
|
25
|
+
try {
|
|
26
|
+
const result = await props.client.orgs.acceptInvitation(props.token)
|
|
27
|
+
state.value = 'success'
|
|
28
|
+
emit('accepted', result.orgId)
|
|
29
|
+
} catch (e) {
|
|
30
|
+
state.value = 'error'
|
|
31
|
+
errorText.value = errorMessage(e, 'This invitation is invalid or has expired.')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function invitationRedirect(): string {
|
|
36
|
+
return props.token ? `/invitations/${encodeURIComponent(props.token)}` : '/sign-in'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onMounted(() => {
|
|
40
|
+
if (props.authenticated && props.client && props.token) {
|
|
41
|
+
void accept()
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
watch(
|
|
46
|
+
() => [props.authenticated, props.client, props.token] as const,
|
|
47
|
+
([authenticated, client, token]) => {
|
|
48
|
+
if (authenticated && client && token && state.value === 'idle') {
|
|
49
|
+
void accept()
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="saas-auth-form">
|
|
57
|
+
<template v-if="!authenticated">
|
|
58
|
+
<p class="saas-muted">
|
|
59
|
+
Sign in or create an account with the invited email address to accept this invitation.
|
|
60
|
+
</p>
|
|
61
|
+
<button type="button" @click="emit('navigate', 'sign-in', invitationRedirect())">
|
|
62
|
+
Sign in
|
|
63
|
+
</button>
|
|
64
|
+
<button type="button" class="saas-btn-secondary" @click="emit('navigate', 'sign-up', invitationRedirect())">
|
|
65
|
+
Create account
|
|
66
|
+
</button>
|
|
67
|
+
</template>
|
|
68
|
+
<template v-else>
|
|
69
|
+
<p v-if="state === 'accepting' || state === 'idle'" class="saas-muted">
|
|
70
|
+
Joining organization…
|
|
71
|
+
</p>
|
|
72
|
+
<p v-else-if="state === 'success'">You've joined the organization.</p>
|
|
73
|
+
<p v-else class="saas-error">{{ errorText }}</p>
|
|
74
|
+
</template>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
title: string
|
|
4
|
+
subtitle?: string
|
|
5
|
+
}>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div class="saas-login">
|
|
10
|
+
<h1>{{ title }}</h1>
|
|
11
|
+
<p v-if="subtitle" class="saas-muted">{{ subtitle }}</p>
|
|
12
|
+
<slot />
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { MaestroClient } from '@bemaestro/sdk/runtime'
|
|
4
|
+
import { errorMessage } from './use-anon-client.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
client: MaestroClient
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
created: [
|
|
12
|
+
result: {
|
|
13
|
+
accessToken?: string
|
|
14
|
+
refreshToken?: string
|
|
15
|
+
orgId: string
|
|
16
|
+
orgName: string
|
|
17
|
+
},
|
|
18
|
+
]
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const name = ref('')
|
|
22
|
+
const error = ref<string | null>(null)
|
|
23
|
+
const loading = ref(false)
|
|
24
|
+
|
|
25
|
+
async function submit() {
|
|
26
|
+
error.value = null
|
|
27
|
+
if (!name.value.trim()) {
|
|
28
|
+
error.value = 'Organization name is required.'
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
loading.value = true
|
|
32
|
+
try {
|
|
33
|
+
const result = await props.client.orgs.create(name.value.trim())
|
|
34
|
+
const org = result.org as { id?: string; name?: string }
|
|
35
|
+
const orgId = org.id
|
|
36
|
+
if (!orgId) {
|
|
37
|
+
throw new Error('Organization was created but no id was returned')
|
|
38
|
+
}
|
|
39
|
+
emit('created', {
|
|
40
|
+
accessToken: result.accessToken,
|
|
41
|
+
refreshToken: result.refreshToken,
|
|
42
|
+
orgId,
|
|
43
|
+
orgName: typeof org.name === 'string' ? org.name : name.value.trim(),
|
|
44
|
+
})
|
|
45
|
+
} catch (e) {
|
|
46
|
+
error.value = errorMessage(e, 'Could not create organization')
|
|
47
|
+
} finally {
|
|
48
|
+
loading.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<form class="saas-auth-form" @submit.prevent="submit">
|
|
55
|
+
<p class="saas-muted">
|
|
56
|
+
Every workspace lives inside an organization — create one to finish setting up your account.
|
|
57
|
+
</p>
|
|
58
|
+
<input
|
|
59
|
+
v-model="name"
|
|
60
|
+
type="text"
|
|
61
|
+
placeholder="Organization name"
|
|
62
|
+
autocomplete="organization"
|
|
63
|
+
required
|
|
64
|
+
/>
|
|
65
|
+
<button type="submit" :disabled="loading">
|
|
66
|
+
{{ loading ? 'Creating…' : 'Create organization' }}
|
|
67
|
+
</button>
|
|
68
|
+
<p v-if="error" class="saas-error">{{ error }}</p>
|
|
69
|
+
</form>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { createAnonClient, type SaasShellConfig } from './use-anon-client.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
config: SaasShellConfig
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
navigate: [screen: 'sign-in' | 'sign-up']
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const email = ref('')
|
|
14
|
+
const loading = ref(false)
|
|
15
|
+
const message = ref<string | null>(null)
|
|
16
|
+
|
|
17
|
+
async function submit() {
|
|
18
|
+
loading.value = true
|
|
19
|
+
message.value = null
|
|
20
|
+
try {
|
|
21
|
+
await createAnonClient(props.config).auth.forgotPassword(email.value)
|
|
22
|
+
} catch {
|
|
23
|
+
// Always-success client message — no user enumeration.
|
|
24
|
+
} finally {
|
|
25
|
+
message.value = `If an account exists, a reset link was sent to ${email.value}`
|
|
26
|
+
email.value = ''
|
|
27
|
+
loading.value = false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<form class="saas-auth-form" @submit.prevent="submit">
|
|
34
|
+
<input
|
|
35
|
+
v-model="email"
|
|
36
|
+
type="email"
|
|
37
|
+
placeholder="Email"
|
|
38
|
+
autocomplete="email"
|
|
39
|
+
required
|
|
40
|
+
/>
|
|
41
|
+
<button type="submit" :disabled="loading">
|
|
42
|
+
{{ loading ? 'Sending…' : 'Continue' }}
|
|
43
|
+
</button>
|
|
44
|
+
<p v-if="message" class="saas-muted">{{ message }}</p>
|
|
45
|
+
<p class="saas-auth-links">
|
|
46
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
47
|
+
Back to sign in
|
|
48
|
+
</button>
|
|
49
|
+
<span class="saas-muted">·</span>
|
|
50
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-up')">
|
|
51
|
+
Create account
|
|
52
|
+
</button>
|
|
53
|
+
</p>
|
|
54
|
+
</form>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { createAnonClient, errorMessage, type SaasShellConfig } from './use-anon-client.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
config: SaasShellConfig
|
|
7
|
+
token: string | null
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
navigate: [screen: 'sign-in' | 'forgot-password']
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const password = ref('')
|
|
15
|
+
const confirmPassword = ref('')
|
|
16
|
+
const error = ref<string | null>(null)
|
|
17
|
+
const loading = ref(false)
|
|
18
|
+
const success = ref(false)
|
|
19
|
+
|
|
20
|
+
async function submit() {
|
|
21
|
+
error.value = null
|
|
22
|
+
if (!props.token) {
|
|
23
|
+
error.value = 'This reset link is missing or malformed.'
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (password.value.length < 8) {
|
|
27
|
+
error.value = 'Password must be at least 8 characters.'
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
if (password.value !== confirmPassword.value) {
|
|
31
|
+
error.value = "Passwords don't match."
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
loading.value = true
|
|
35
|
+
try {
|
|
36
|
+
await createAnonClient(props.config).auth.resetPassword({
|
|
37
|
+
token: props.token,
|
|
38
|
+
password: password.value,
|
|
39
|
+
})
|
|
40
|
+
success.value = true
|
|
41
|
+
} catch (e) {
|
|
42
|
+
error.value = errorMessage(e, 'This reset link is invalid or has expired.')
|
|
43
|
+
} finally {
|
|
44
|
+
loading.value = false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div v-if="success" class="saas-auth-form">
|
|
51
|
+
<p>
|
|
52
|
+
Password updated. All active sessions were signed out — please sign in again.
|
|
53
|
+
</p>
|
|
54
|
+
<p class="saas-auth-links">
|
|
55
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
56
|
+
Back to sign in
|
|
57
|
+
</button>
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
<form v-else class="saas-auth-form" @submit.prevent="submit">
|
|
61
|
+
<p v-if="!token" class="saas-error">This reset link is missing or malformed.</p>
|
|
62
|
+
<template v-else>
|
|
63
|
+
<input
|
|
64
|
+
v-model="password"
|
|
65
|
+
type="password"
|
|
66
|
+
placeholder="New password"
|
|
67
|
+
autocomplete="new-password"
|
|
68
|
+
required
|
|
69
|
+
minlength="8"
|
|
70
|
+
/>
|
|
71
|
+
<input
|
|
72
|
+
v-model="confirmPassword"
|
|
73
|
+
type="password"
|
|
74
|
+
placeholder="Confirm new password"
|
|
75
|
+
autocomplete="new-password"
|
|
76
|
+
required
|
|
77
|
+
/>
|
|
78
|
+
<button type="submit" :disabled="loading">
|
|
79
|
+
{{ loading ? 'Updating…' : 'Reset password' }}
|
|
80
|
+
</button>
|
|
81
|
+
</template>
|
|
82
|
+
<p v-if="error" class="saas-error">{{ error }}</p>
|
|
83
|
+
<p class="saas-auth-links">
|
|
84
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'forgot-password')">
|
|
85
|
+
Request a new link
|
|
86
|
+
</button>
|
|
87
|
+
<span class="saas-muted">·</span>
|
|
88
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
89
|
+
Sign in
|
|
90
|
+
</button>
|
|
91
|
+
</p>
|
|
92
|
+
</form>
|
|
93
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { MaestroClient } from '@bemaestro/sdk/runtime'
|
|
4
|
+
import type { SaasOrgMembership } from '../session.js'
|
|
5
|
+
import { errorMessage } from './use-anon-client.js'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
client: MaestroClient
|
|
9
|
+
orgs: SaasOrgMembership[]
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
selected: [
|
|
14
|
+
result: {
|
|
15
|
+
accessToken: string
|
|
16
|
+
refreshToken: string
|
|
17
|
+
orgId: string
|
|
18
|
+
role: string
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const error = ref<string | null>(null)
|
|
24
|
+
const loadingId = ref<string | null>(null)
|
|
25
|
+
|
|
26
|
+
async function select(orgId: string) {
|
|
27
|
+
error.value = null
|
|
28
|
+
loadingId.value = orgId
|
|
29
|
+
try {
|
|
30
|
+
const result = await props.client.auth.switchOrg(orgId)
|
|
31
|
+
const membership = result.orgs.find((o) => o.orgId === orgId)
|
|
32
|
+
emit('selected', {
|
|
33
|
+
accessToken: result.accessToken,
|
|
34
|
+
refreshToken: result.refreshToken,
|
|
35
|
+
orgId,
|
|
36
|
+
role: membership?.role ?? 'member',
|
|
37
|
+
})
|
|
38
|
+
} catch (e) {
|
|
39
|
+
error.value = errorMessage(e, 'Could not switch organization')
|
|
40
|
+
} finally {
|
|
41
|
+
loadingId.value = null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div class="saas-auth-form">
|
|
48
|
+
<p class="saas-muted">Choose which organization to open.</p>
|
|
49
|
+
<ul class="saas-list">
|
|
50
|
+
<li v-for="org in orgs" :key="org.orgId">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class="saas-org-pick"
|
|
54
|
+
:disabled="loadingId !== null"
|
|
55
|
+
@click="select(org.orgId)"
|
|
56
|
+
>
|
|
57
|
+
<strong>{{ org.orgName }}</strong>
|
|
58
|
+
<span class="saas-muted">{{ org.role }}</span>
|
|
59
|
+
<span v-if="loadingId === org.orgId" class="saas-muted">Opening…</span>
|
|
60
|
+
</button>
|
|
61
|
+
</li>
|
|
62
|
+
</ul>
|
|
63
|
+
<p v-if="error" class="saas-error">{{ error }}</p>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { MaestroHttpError } from '@bemaestro/sdk/runtime'
|
|
4
|
+
import { createAnonClient, errorCode, errorMessage, type SaasShellConfig } from './use-anon-client.js'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
config: SaasShellConfig
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
signedIn: [
|
|
12
|
+
result: {
|
|
13
|
+
accessToken: string
|
|
14
|
+
refreshToken: string
|
|
15
|
+
user: { id: string; email: string; name: string }
|
|
16
|
+
orgs: Array<{
|
|
17
|
+
orgId: string
|
|
18
|
+
orgName: string
|
|
19
|
+
orgStatus: string
|
|
20
|
+
role: string
|
|
21
|
+
membershipStatus: string
|
|
22
|
+
}>
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
navigate: [screen: 'sign-up' | 'forgot-password']
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const email = ref('')
|
|
29
|
+
const password = ref('')
|
|
30
|
+
const error = ref<string | null>(null)
|
|
31
|
+
const loading = ref(false)
|
|
32
|
+
|
|
33
|
+
async function submit() {
|
|
34
|
+
error.value = null
|
|
35
|
+
loading.value = true
|
|
36
|
+
try {
|
|
37
|
+
const result = await createAnonClient(props.config).auth.login({
|
|
38
|
+
email: email.value,
|
|
39
|
+
password: password.value,
|
|
40
|
+
})
|
|
41
|
+
emit('signedIn', result)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const code = errorCode(e)
|
|
44
|
+
if (code === 'EMAIL_UNVERIFIED') {
|
|
45
|
+
error.value =
|
|
46
|
+
'Email verification required. Check your inbox for the verification link before signing in.'
|
|
47
|
+
} else if (e instanceof MaestroHttpError && e.status === 429) {
|
|
48
|
+
error.value = 'Too many failed attempts. Please wait a few minutes and try again.'
|
|
49
|
+
} else {
|
|
50
|
+
error.value = errorMessage(e, 'Sign-in failed')
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
loading.value = false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<form class="saas-auth-form" @submit.prevent="submit">
|
|
60
|
+
<input
|
|
61
|
+
v-model="email"
|
|
62
|
+
type="email"
|
|
63
|
+
placeholder="Email"
|
|
64
|
+
autocomplete="username"
|
|
65
|
+
required
|
|
66
|
+
/>
|
|
67
|
+
<input
|
|
68
|
+
v-model="password"
|
|
69
|
+
type="password"
|
|
70
|
+
placeholder="Password"
|
|
71
|
+
autocomplete="current-password"
|
|
72
|
+
required
|
|
73
|
+
/>
|
|
74
|
+
<button type="submit" :disabled="loading">
|
|
75
|
+
{{ loading ? 'Signing in…' : 'Sign in' }}
|
|
76
|
+
</button>
|
|
77
|
+
<p v-if="error" class="saas-error">{{ error }}</p>
|
|
78
|
+
<p class="saas-auth-links">
|
|
79
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'forgot-password')">
|
|
80
|
+
Forgot password?
|
|
81
|
+
</button>
|
|
82
|
+
<span class="saas-muted">·</span>
|
|
83
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-up')">
|
|
84
|
+
Create account
|
|
85
|
+
</button>
|
|
86
|
+
</p>
|
|
87
|
+
</form>
|
|
88
|
+
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { createAnonClient, errorMessage, type SaasShellConfig } from './use-anon-client.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
config: SaasShellConfig
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
registered: [email: string]
|
|
11
|
+
navigate: [screen: 'sign-in']
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const name = ref('')
|
|
15
|
+
const email = ref('')
|
|
16
|
+
const password = ref('')
|
|
17
|
+
const confirmPassword = ref('')
|
|
18
|
+
const error = ref<string | null>(null)
|
|
19
|
+
const loading = ref(false)
|
|
20
|
+
const doneEmail = ref<string | null>(null)
|
|
21
|
+
|
|
22
|
+
async function submit() {
|
|
23
|
+
error.value = null
|
|
24
|
+
if (password.value.length < 8) {
|
|
25
|
+
error.value = 'Password must be at least 8 characters.'
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (password.value !== confirmPassword.value) {
|
|
29
|
+
error.value = "Passwords don't match."
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
loading.value = true
|
|
33
|
+
try {
|
|
34
|
+
await createAnonClient(props.config).auth.register({
|
|
35
|
+
email: email.value,
|
|
36
|
+
password: password.value,
|
|
37
|
+
name: name.value,
|
|
38
|
+
})
|
|
39
|
+
doneEmail.value = email.value
|
|
40
|
+
emit('registered', email.value)
|
|
41
|
+
} catch (e) {
|
|
42
|
+
error.value = errorMessage(e, 'Could not create account')
|
|
43
|
+
} finally {
|
|
44
|
+
loading.value = false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div v-if="doneEmail" class="saas-auth-form">
|
|
51
|
+
<p>
|
|
52
|
+
Check your email — we sent a verification link to
|
|
53
|
+
<strong>{{ doneEmail }}</strong>.
|
|
54
|
+
</p>
|
|
55
|
+
<p class="saas-auth-links">
|
|
56
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
57
|
+
Back to sign in
|
|
58
|
+
</button>
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
<form v-else class="saas-auth-form" @submit.prevent="submit">
|
|
62
|
+
<input
|
|
63
|
+
v-model="name"
|
|
64
|
+
type="text"
|
|
65
|
+
placeholder="Name"
|
|
66
|
+
autocomplete="name"
|
|
67
|
+
required
|
|
68
|
+
/>
|
|
69
|
+
<input
|
|
70
|
+
v-model="email"
|
|
71
|
+
type="email"
|
|
72
|
+
placeholder="Email"
|
|
73
|
+
autocomplete="email"
|
|
74
|
+
required
|
|
75
|
+
/>
|
|
76
|
+
<input
|
|
77
|
+
v-model="password"
|
|
78
|
+
type="password"
|
|
79
|
+
placeholder="Password (min 8 characters)"
|
|
80
|
+
autocomplete="new-password"
|
|
81
|
+
required
|
|
82
|
+
minlength="8"
|
|
83
|
+
/>
|
|
84
|
+
<input
|
|
85
|
+
v-model="confirmPassword"
|
|
86
|
+
type="password"
|
|
87
|
+
placeholder="Confirm password"
|
|
88
|
+
autocomplete="new-password"
|
|
89
|
+
required
|
|
90
|
+
/>
|
|
91
|
+
<button type="submit" :disabled="loading">
|
|
92
|
+
{{ loading ? 'Creating…' : 'Create account' }}
|
|
93
|
+
</button>
|
|
94
|
+
<p v-if="error" class="saas-error">{{ error }}</p>
|
|
95
|
+
<p class="saas-auth-links">
|
|
96
|
+
<span class="saas-muted">Already have an account?</span>
|
|
97
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
98
|
+
Sign in
|
|
99
|
+
</button>
|
|
100
|
+
</p>
|
|
101
|
+
</form>
|
|
102
|
+
</template>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { createAnonClient, errorMessage, type SaasShellConfig } from './use-anon-client.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
config: SaasShellConfig
|
|
7
|
+
token: string | null
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
navigate: [screen: 'sign-in']
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
type State = 'verifying' | 'success' | 'error'
|
|
15
|
+
const state = ref<State>('verifying')
|
|
16
|
+
const errorMessageText = ref('')
|
|
17
|
+
|
|
18
|
+
onMounted(async () => {
|
|
19
|
+
if (!props.token) {
|
|
20
|
+
state.value = 'error'
|
|
21
|
+
errorMessageText.value = 'This verification link is missing or malformed.'
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await createAnonClient(props.config).auth.verifyEmail(props.token)
|
|
26
|
+
state.value = 'success'
|
|
27
|
+
} catch (e) {
|
|
28
|
+
state.value = 'error'
|
|
29
|
+
errorMessageText.value = errorMessage(
|
|
30
|
+
e,
|
|
31
|
+
'This verification link is invalid or has expired.',
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div class="saas-auth-form">
|
|
39
|
+
<p v-if="state === 'verifying'" class="saas-muted">Confirming your email address…</p>
|
|
40
|
+
<p v-else-if="state === 'success'">Your email has been verified. You can sign in now.</p>
|
|
41
|
+
<p v-else class="saas-error">{{ errorMessageText }}</p>
|
|
42
|
+
<p v-if="state !== 'verifying'" class="saas-auth-links">
|
|
43
|
+
<button type="button" class="saas-link" @click="emit('navigate', 'sign-in')">
|
|
44
|
+
Back to sign in
|
|
45
|
+
</button>
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|