@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.
@@ -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>