@jskit-ai/auth-web 0.1.9 → 0.1.11

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.
@@ -12,7 +12,7 @@
12
12
  <v-chip color="primary" size="small" label>Secure</v-chip>
13
13
  </div>
14
14
 
15
- <div v-if="!isForgot && !isOtp" class="mode-switch d-flex ga-2 pa-1 mb-5">
15
+ <div v-if="!isForgot && !isOtp && !isEmailConfirmationPending" class="mode-switch d-flex ga-2 pa-1 mb-5">
16
16
  <v-btn
17
17
  data-testid="auth-mode-sign-in"
18
18
  class="text-none"
@@ -35,140 +35,166 @@
35
35
  </div>
36
36
 
37
37
  <v-form @submit.prevent="submitAuth" novalidate>
38
- <div
39
- v-if="showRememberedAccount"
40
- class="remembered-account d-flex align-center justify-space-between ga-3 mb-4"
41
- >
42
- <div class="remembered-copy flex-grow-1">
43
- <p class="remembered-title">Welcome back, {{ rememberedAccountDisplayName }}</p>
44
- <p class="remembered-email">{{ rememberedAccountMaskedEmail }}</p>
38
+ <template v-if="isEmailConfirmationPending">
39
+ <div class="email-confirmation-state mb-5">
40
+ <p class="email-confirmation-message mb-0">
41
+ {{ emailConfirmationMessage }}
42
+ </p>
45
43
  </div>
46
- <v-btn variant="text" color="secondary" class="text-none" @click="switchAccount">
47
- {{ rememberedAccountSwitchLabel }}
48
- </v-btn>
49
- </div>
50
-
51
- <v-text-field
52
- v-model="email"
53
- label="Email"
54
- variant="outlined"
55
- density="comfortable"
56
- type="email"
57
- autocomplete="email"
58
- :error-messages="emailErrorMessages"
59
- @blur="emailTouched = true"
60
- class="mb-3"
61
- />
62
-
63
- <v-text-field
64
- v-if="!isForgot && !isOtp"
65
- v-model="password"
66
- label="Password"
67
- :type="showPassword ? 'text' : 'password'"
68
- variant="outlined"
69
- density="comfortable"
70
- :autocomplete="isRegister ? 'new-password' : 'current-password'"
71
- :error-messages="passwordErrorMessages"
72
- :append-inner-icon="showPassword ? '$eyeOff' : '$eye'"
73
- @click:append-inner="showPassword = !showPassword"
74
- @blur="passwordTouched = true"
75
- class="mb-3"
76
- />
77
-
78
- <v-text-field
79
- v-if="isRegister"
80
- v-model="confirmPassword"
81
- label="Confirm password"
82
- :type="showConfirmPassword ? 'text' : 'password'"
83
- variant="outlined"
84
- density="comfortable"
85
- autocomplete="new-password"
86
- :error-messages="confirmPasswordErrorMessages"
87
- :append-inner-icon="showConfirmPassword ? '$eyeOff' : '$eye'"
88
- @click:append-inner="showConfirmPassword = !showConfirmPassword"
89
- @blur="confirmPasswordTouched = true"
90
- class="mb-3"
91
- />
92
-
93
- <v-text-field
94
- v-if="isOtp"
95
- v-model="otpCode"
96
- label="One-time code"
97
- variant="outlined"
98
- density="comfortable"
99
- autocomplete="one-time-code"
100
- :error-messages="otpCodeErrorMessages"
101
- @blur="otpCodeTouched = true"
102
- class="mb-3"
103
- />
104
-
105
- <div v-if="isLogin" class="aux-links d-flex justify-end mb-4">
106
- <v-btn variant="text" color="secondary" @click="switchMode('forgot')">Forgot password?</v-btn>
107
- <v-btn variant="text" color="secondary" @click="switchMode('otp')">Use one-time code</v-btn>
108
- </div>
109
44
 
110
- <v-checkbox
111
- v-if="isLogin || isOtp"
112
- v-model="rememberAccountOnDevice"
113
- label="Remember this account on this device"
114
- density="compact"
115
- hide-details
116
- class="mb-4"
117
- />
45
+ <div class="switch-row d-flex align-center justify-space-between ga-3">
46
+ <v-btn class="text-none" variant="tonal" color="primary" @click="goToMainScreen">
47
+ Go to main screen
48
+ </v-btn>
49
+ <v-btn
50
+ class="text-none"
51
+ variant="outlined"
52
+ color="secondary"
53
+ :loading="registerConfirmationResendPending"
54
+ @click="resendRegisterConfirmationEmail"
55
+ >
56
+ Resend confirmation email
57
+ </v-btn>
58
+ <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
59
+ </div>
60
+ </template>
118
61
 
119
- <div v-if="isOtp" class="aux-links d-flex justify-end mb-4">
120
- <v-btn
121
- type="button"
122
- variant="tonal"
123
- color="secondary"
124
- :loading="otpRequestPending"
125
- @click="requestOtpCode"
62
+ <template v-else>
63
+ <div
64
+ v-if="showRememberedAccount"
65
+ class="remembered-account d-flex align-center justify-space-between ga-3 mb-4"
126
66
  >
127
- Send one-time code
128
- </v-btn>
129
- </div>
67
+ <div class="remembered-copy flex-grow-1">
68
+ <p class="remembered-title">Welcome back, {{ rememberedAccountDisplayName }}</p>
69
+ <p class="remembered-email">{{ rememberedAccountMaskedEmail }}</p>
70
+ </div>
71
+ <v-btn variant="text" color="secondary" class="text-none" @click="switchAccount">
72
+ {{ rememberedAccountSwitchLabel }}
73
+ </v-btn>
74
+ </div>
75
+
76
+ <v-text-field
77
+ v-model="email"
78
+ label="Email"
79
+ variant="outlined"
80
+ density="comfortable"
81
+ type="email"
82
+ autocomplete="email"
83
+ :error-messages="emailErrorMessages"
84
+ @blur="emailTouched = true"
85
+ class="mb-3"
86
+ />
87
+
88
+ <v-text-field
89
+ v-if="!isForgot && !isOtp"
90
+ v-model="password"
91
+ label="Password"
92
+ :type="showPassword ? 'text' : 'password'"
93
+ variant="outlined"
94
+ density="comfortable"
95
+ :autocomplete="isRegister ? 'new-password' : 'current-password'"
96
+ :error-messages="passwordErrorMessages"
97
+ :append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
98
+ @click:append-inner="togglePasswordVisibility"
99
+ @blur="passwordTouched = true"
100
+ class="mb-3"
101
+ />
102
+
103
+ <v-text-field
104
+ v-if="isRegister"
105
+ v-model="confirmPassword"
106
+ label="Confirm password"
107
+ :type="showConfirmPassword ? 'text' : 'password'"
108
+ variant="outlined"
109
+ density="comfortable"
110
+ autocomplete="new-password"
111
+ :error-messages="confirmPasswordErrorMessages"
112
+ :append-inner-icon="showConfirmPassword ? mdiEyeOff : mdiEye"
113
+ @click:append-inner="toggleConfirmPasswordVisibility"
114
+ @blur="confirmPasswordTouched = true"
115
+ class="mb-3"
116
+ />
117
+
118
+ <v-text-field
119
+ v-if="isOtp"
120
+ v-model="otpCode"
121
+ label="One-time code"
122
+ variant="outlined"
123
+ density="comfortable"
124
+ autocomplete="one-time-code"
125
+ :error-messages="otpCodeErrorMessages"
126
+ @blur="otpCodeTouched = true"
127
+ class="mb-3"
128
+ />
130
129
 
131
- <div v-if="isLogin || isRegister" class="oauth-actions d-grid ga-2 mb-4">
130
+ <div v-if="isLogin" class="aux-links d-flex justify-end mb-4">
131
+ <v-btn variant="text" color="secondary" @click="switchMode('forgot')">Forgot password?</v-btn>
132
+ <v-btn variant="text" color="secondary" @click="switchMode('otp')">Use one-time code</v-btn>
133
+ </div>
134
+
135
+ <v-checkbox
136
+ v-if="isLogin || isOtp"
137
+ v-model="rememberAccountOnDevice"
138
+ label="Remember this account on this device"
139
+ density="compact"
140
+ hide-details
141
+ class="mb-4"
142
+ />
143
+
144
+ <div v-if="isOtp" class="aux-links d-flex justify-end mb-4">
145
+ <v-btn
146
+ type="button"
147
+ variant="tonal"
148
+ color="secondary"
149
+ :loading="otpRequestPending"
150
+ @click="requestOtpCode"
151
+ >
152
+ Send one-time code
153
+ </v-btn>
154
+ </div>
155
+
156
+ <div v-if="isLogin || isRegister" class="oauth-actions d-grid ga-2 mb-4">
157
+ <v-btn
158
+ v-for="provider in oauthProviders"
159
+ :key="provider.id"
160
+ block
161
+ variant="outlined"
162
+ color="secondary"
163
+ :disabled="loading"
164
+ :prepend-icon="oauthProviderIcon(provider)"
165
+ class="text-none oauth-provider-button"
166
+ @click="startOAuthSignIn(provider.id)"
167
+ >
168
+ {{ oauthProviderButtonLabel(provider) }}
169
+ </v-btn>
170
+ </div>
132
171
  <v-btn
133
- v-for="provider in oauthProviders"
134
- :key="provider.id"
172
+ data-testid="auth-submit"
135
173
  block
136
- variant="outlined"
137
- color="secondary"
138
- :disabled="loading"
139
- :prepend-icon="oauthProviderIcon(provider)"
140
- class="text-none oauth-provider-button"
141
- @click="startOAuthSignIn(provider.id)"
174
+ color="primary"
175
+ size="large"
176
+ :loading="loading"
177
+ :disabled="!canSubmit"
178
+ type="submit"
142
179
  >
143
- {{ oauthProviderButtonLabel(provider) }}
180
+ {{ submitLabel }}
144
181
  </v-btn>
145
- </div>
146
- <v-btn
147
- data-testid="auth-submit"
148
- block
149
- color="primary"
150
- size="large"
151
- :loading="loading"
152
- :disabled="!canSubmit"
153
- type="submit"
154
- >
155
- {{ submitLabel }}
156
- </v-btn>
157
182
 
158
- <div v-if="isRegister" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
159
- <span class="text-medium-emphasis">Already have an account?</span>
160
- <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
161
- </div>
183
+ <div v-if="isRegister" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
184
+ <span class="text-medium-emphasis">Already have an account?</span>
185
+ <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
186
+ </div>
162
187
 
163
- <div v-else-if="isForgot" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
164
- <span class="text-medium-emphasis">Remembered your password?</span>
165
- <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
166
- </div>
188
+ <div v-else-if="isForgot" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
189
+ <span class="text-medium-emphasis">Remembered your password?</span>
190
+ <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
191
+ </div>
167
192
 
168
- <div v-else-if="isOtp" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
169
- <span class="text-medium-emphasis">Want to use another method?</span>
170
- <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
171
- </div>
193
+ <div v-else-if="isOtp" class="switch-row mt-4 d-flex align-center justify-space-between ga-3">
194
+ <span class="text-medium-emphasis">Want to use another method?</span>
195
+ <v-btn variant="text" color="secondary" @click="switchMode('login')">Back to sign in</v-btn>
196
+ </div>
197
+ </template>
172
198
  </v-form>
173
199
  </v-card-text>
174
200
  </v-card>
@@ -177,7 +203,31 @@
177
203
  </template>
178
204
 
179
205
  <script setup>
180
- import { useDefaultLoginView } from "../composables/useDefaultLoginView.js";
206
+ import { onMounted } from "vue";
207
+ import { useQueryClient } from "@tanstack/vue-query";
208
+ import { mdiEye, mdiEyeOff } from "@mdi/js";
209
+ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
210
+ import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
211
+ import { useLoginViewState } from "../composables/loginView/useLoginViewState.js";
212
+ import { useLoginViewValidation } from "../composables/loginView/useLoginViewValidation.js";
213
+ import { useLoginViewActions } from "../composables/loginView/useLoginViewActions.js";
214
+
215
+ const {
216
+ context: placementContext
217
+ } = useWebPlacementContext();
218
+ const queryClient = useQueryClient();
219
+ const errorRuntime = useShellWebErrorRuntime();
220
+
221
+ const state = useLoginViewState({ placementContext });
222
+ const validation = useLoginViewValidation({ state });
223
+ const actions = useLoginViewActions({
224
+ state,
225
+ validation,
226
+ queryClient,
227
+ errorRuntime
228
+ });
229
+
230
+ onMounted(actions.initializeOnMounted);
181
231
 
182
232
  const {
183
233
  authTitle,
@@ -186,38 +236,57 @@ const {
186
236
  isOtp,
187
237
  isLogin,
188
238
  isRegister,
239
+ isEmailConfirmationPending,
240
+ emailConfirmationMessage,
189
241
  showRememberedAccount,
190
242
  switchMode,
191
- submitAuth,
243
+ goToMainScreen,
192
244
  rememberedAccountDisplayName,
193
245
  rememberedAccountMaskedEmail,
194
246
  rememberedAccountSwitchLabel,
195
247
  switchAccount,
196
248
  email,
197
- emailErrorMessages,
198
249
  emailTouched,
199
250
  password,
200
251
  showPassword,
201
- passwordErrorMessages,
202
252
  passwordTouched,
203
253
  confirmPassword,
204
254
  showConfirmPassword,
205
- confirmPasswordErrorMessages,
206
255
  confirmPasswordTouched,
207
256
  otpCode,
208
- otpCodeErrorMessages,
209
257
  otpCodeTouched,
210
258
  rememberAccountOnDevice,
211
259
  otpRequestPending,
212
- requestOtpCode,
260
+ registerConfirmationResendPending,
213
261
  oauthProviders,
214
262
  loading,
263
+ submitLabel
264
+ } = state;
265
+
266
+ const {
267
+ emailErrorMessages,
268
+ passwordErrorMessages,
269
+ confirmPasswordErrorMessages,
270
+ otpCodeErrorMessages,
271
+ canSubmit
272
+ } = validation;
273
+
274
+ const {
275
+ submitAuth,
276
+ requestOtpCode,
277
+ resendRegisterConfirmationEmail,
215
278
  oauthProviderIcon,
216
279
  startOAuthSignIn,
217
- oauthProviderButtonLabel,
218
- canSubmit,
219
- submitLabel
220
- } = useDefaultLoginView();
280
+ oauthProviderButtonLabel
281
+ } = actions;
282
+
283
+ function togglePasswordVisibility() {
284
+ showPassword.value = !showPassword.value;
285
+ }
286
+
287
+ function toggleConfirmPasswordVisibility() {
288
+ showConfirmPassword.value = !showConfirmPassword.value;
289
+ }
221
290
  </script>
222
291
 
223
292
  <style scoped>
@@ -251,6 +320,18 @@ const {
251
320
  background-color: rgba(57, 84, 71, 0.08);
252
321
  }
253
322
 
323
+ .email-confirmation-state {
324
+ border-radius: 12px;
325
+ border: 1px solid rgba(57, 84, 71, 0.2);
326
+ background: rgba(57, 84, 71, 0.07);
327
+ padding: 16px;
328
+ }
329
+
330
+ .email-confirmation-message {
331
+ color: #1d2c24;
332
+ line-height: 1.5;
333
+ }
334
+
254
335
  .remembered-account {
255
336
  border-radius: 12px;
256
337
  border: 1px solid rgba(57, 84, 71, 0.2);
@@ -1,5 +1,6 @@
1
1
  const AUTH_ACTION_IDS = Object.freeze({
2
2
  REGISTER: "auth.register",
3
+ REGISTER_CONFIRMATION_RESEND: "auth.register.confirmation.resend",
3
4
  LOGIN_PASSWORD: "auth.login.password",
4
5
  LOGIN_OTP_REQUEST: "auth.login.otp.request",
5
6
  LOGIN_OTP_VERIFY: "auth.login.otp.verify",
@@ -35,6 +35,12 @@ class AuthController {
35
35
  });
36
36
  }
37
37
 
38
+ async resendRegisterConfirmation(request, reply) {
39
+ const payload = request.body || {};
40
+ const result = await this.service.resendRegisterConfirmation(request, payload);
41
+ reply.code(200).send(result);
42
+ }
43
+
38
44
  async login(request, reply) {
39
45
  const payload = request.body || {};
40
46
  const result = await this.service.login(request, payload);
@@ -1,15 +1,18 @@
1
1
  import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
- import { authRegisterCommand } from "@jskit-ai/auth-core/shared/commands/authRegisterCommand";
3
- import { authLoginPasswordCommand } from "@jskit-ai/auth-core/shared/commands/authLoginPasswordCommand";
4
- import { authLoginOtpRequestCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpRequestCommand";
5
- import { authLoginOtpVerifyCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOtpVerifyCommand";
6
- import { authLoginOAuthStartCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthStartCommand";
7
- import { authLoginOAuthCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authLoginOAuthCompleteCommand";
8
- import { authPasswordResetRequestCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetRequestCommand";
9
- import { authPasswordRecoveryCompleteCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordRecoveryCompleteCommand";
10
- import { authPasswordResetCommand } from "@jskit-ai/auth-core/shared/commands/authPasswordResetCommand";
11
- import { authLogoutCommand } from "@jskit-ai/auth-core/shared/commands/authLogoutCommand";
12
- import { authSessionReadCommand } from "@jskit-ai/auth-core/shared/commands/authSessionReadCommand";
2
+ import {
3
+ authRegisterCommand,
4
+ authRegisterConfirmationResendCommand,
5
+ authLoginPasswordCommand,
6
+ authLoginOtpRequestCommand,
7
+ authLoginOtpVerifyCommand,
8
+ authLoginOAuthStartCommand,
9
+ authLoginOAuthCompleteCommand,
10
+ authPasswordResetRequestCommand,
11
+ authPasswordRecoveryCompleteCommand,
12
+ authPasswordResetCommand,
13
+ authLogoutCommand,
14
+ authSessionReadCommand
15
+ } from "@jskit-ai/auth-core/shared/commands";
13
16
  import { AUTH_PATHS } from "@jskit-ai/auth-core/shared/authPaths";
14
17
 
15
18
  function buildRoutes(controller) {
@@ -41,6 +44,27 @@ function buildRoutes(controller) {
41
44
  },
42
45
  handler: handler("register")
43
46
  },
47
+ {
48
+ path: AUTH_PATHS.REGISTER_CONFIRMATION_RESEND,
49
+ method: "POST",
50
+ auth: "public",
51
+ meta: {
52
+ tags: ["auth"],
53
+ summary: "Resend sign-up email confirmation"
54
+ },
55
+ bodyValidator: authRegisterConfirmationResendCommand.operation.bodyValidator,
56
+ responseValidators: withStandardErrorResponses(
57
+ {
58
+ 200: authRegisterConfirmationResendCommand.operation.responseValidator
59
+ },
60
+ { includeValidation400: true }
61
+ ),
62
+ rateLimit: {
63
+ max: 5,
64
+ timeWindow: "1 minute"
65
+ },
66
+ handler: handler("resendRegisterConfirmation")
67
+ },
44
68
  {
45
69
  path: AUTH_PATHS.LOGIN,
46
70
  method: "POST",
@@ -19,6 +19,13 @@ class AuthWebService {
19
19
  });
20
20
  }
21
21
 
22
+ async resendRegisterConfirmation(request, payload) {
23
+ return request.executeAction({
24
+ actionId: AUTH_ACTION_IDS.REGISTER_CONFIRMATION_RESEND,
25
+ input: payload
26
+ });
27
+ }
28
+
22
29
  async login(request, payload) {
23
30
  return request.executeAction({
24
31
  actionId: AUTH_ACTION_IDS.LOGIN_PASSWORD,
@@ -105,6 +105,50 @@ test("auth guard runtime keeps previous auth state on transient refresh failure"
105
105
  assert.equal(placementRuntime.setCalls.length, setCallCountBeforeTransientFailure);
106
106
  });
107
107
 
108
+ test("auth guard runtime redirects callback hashes on non-login routes to /auth/login", async () => {
109
+ const originalWindow = globalThis.window;
110
+ let redirectedTo = "";
111
+ globalThis.window = {
112
+ location: {
113
+ pathname: "/home",
114
+ search: "",
115
+ hash: "#access_token=access&refresh_token=refresh",
116
+ replace(target) {
117
+ redirectedTo = String(target || "");
118
+ }
119
+ }
120
+ };
121
+
122
+ try {
123
+ const placementRuntime = createPlacementRuntimeStub();
124
+ let fetchCalls = 0;
125
+ const runtime = createAuthGuardRuntime({
126
+ placementRuntime,
127
+ fetchImplementation: async () => {
128
+ fetchCalls += 1;
129
+ return {
130
+ ok: true,
131
+ async json() {
132
+ return {
133
+ authenticated: false
134
+ };
135
+ }
136
+ };
137
+ }
138
+ });
139
+
140
+ const state = await runtime.initialize();
141
+ assert.equal(state.authenticated, false);
142
+ assert.equal(fetchCalls, 0);
143
+ assert.equal(
144
+ redirectedTo,
145
+ "/auth/login?returnTo=%2Fhome#access_token=access&refresh_token=refresh"
146
+ );
147
+ } finally {
148
+ globalThis.window = originalWindow;
149
+ }
150
+ });
151
+
108
152
  test("auth guard runtime only updates placement auth context", async () => {
109
153
  const placementRuntime = createPlacementRuntimeStub({
110
154
  user: {
@@ -51,13 +51,18 @@ test("auth-web removes legacy client wrapper modules", () => {
51
51
  }
52
52
  });
53
53
 
54
- test("auth-web runtime/useLoginView delegates to useDefaultLoginView", () => {
54
+ test("auth-web runtime/useLoginView composes login view state, validation, and actions", () => {
55
55
  const runtimeUseLoginViewPath = fileURLToPath(new URL("../src/client/runtime/useLoginView.js", import.meta.url));
56
56
  const runtimeUseLoginViewSource = readFileSync(runtimeUseLoginViewPath, "utf8");
57
57
 
58
- assert.match(runtimeUseLoginViewSource, /import\s+\{\s*useDefaultLoginView\s*\}\s+from\s+"..\/composables\/useDefaultLoginView\.js";/);
59
- assert.match(runtimeUseLoginViewSource, /function\s+useLoginView\s*\(\)\s*\{\s*return\s+useDefaultLoginView\(\);\s*\}/);
60
- assert.match(runtimeUseLoginViewSource, /export\s+\{\s*useLoginView,\s*useDefaultLoginView\s*\};/);
58
+ assert.match(runtimeUseLoginViewSource, /import\s+\{\s*useLoginViewState\s*\}\s+from\s+"..\/composables\/loginView\/useLoginViewState\.js";/);
59
+ assert.match(runtimeUseLoginViewSource, /import\s+\{\s*useLoginViewValidation\s*\}\s+from\s+"..\/composables\/loginView\/useLoginViewValidation\.js";/);
60
+ assert.match(runtimeUseLoginViewSource, /import\s+\{\s*useLoginViewActions\s*\}\s+from\s+"..\/composables\/loginView\/useLoginViewActions\.js";/);
61
+ assert.match(runtimeUseLoginViewSource, /const\s+state\s*=\s*useLoginViewState\(/);
62
+ assert.match(runtimeUseLoginViewSource, /const\s+validation\s*=\s*useLoginViewValidation\(\s*\{\s*state\s*\}\s*\)/);
63
+ assert.match(runtimeUseLoginViewSource, /const\s+actions\s*=\s*useLoginViewActions\(/);
64
+ assert.doesNotMatch(runtimeUseLoginViewSource, /useDefaultLoginView/);
65
+ assert.match(runtimeUseLoginViewSource, /export\s+\{\s*useLoginView\s*\};/);
61
66
  });
62
67
 
63
68
  test("auth-web package exports only minimal client runtime/view subpaths", () => {
@@ -56,6 +56,12 @@ test("auth route provider registers routes and executes login/logout handlers",
56
56
  app.instance("authService", authService);
57
57
  app.instance("actionExecutor", {
58
58
  async execute({ actionId }) {
59
+ if (actionId === "auth.register.confirmation.resend") {
60
+ return {
61
+ ok: true,
62
+ message: "If an account exists for that email, a confirmation email has been sent."
63
+ };
64
+ }
59
65
  if (actionId === "auth.login.password") {
60
66
  return {
61
67
  session: { access_token: "a", refresh_token: "r" },
@@ -88,6 +94,15 @@ test("auth route provider registers routes and executes login/logout handlers",
88
94
  assert.equal(loginReply.statusCode, 200);
89
95
  assert.equal(loginReply.payload.username, "Ada");
90
96
 
97
+ const resendConfirmationRoute = fastify.routes.find(
98
+ (route) => route.method === "POST" && route.url === "/api/register/confirmation/resend"
99
+ );
100
+ assert.ok(resendConfirmationRoute);
101
+ const resendConfirmationReply = createReplyStub();
102
+ await resendConfirmationRoute.handler({ body: { email: "ada@example.com" } }, resendConfirmationReply);
103
+ assert.equal(resendConfirmationReply.statusCode, 200);
104
+ assert.equal(resendConfirmationReply.payload.ok, true);
105
+
91
106
  const logoutRoute = fastify.routes.find((route) => route.method === "POST" && route.url === "/api/logout");
92
107
  assert.ok(logoutRoute);
93
108
  const logoutReply = createReplyStub();
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { resolveRegisterCompletionState } from "../src/client/composables/loginView/registerCompletion.js";
4
+
5
+ test("resolveRegisterCompletionState requires no immediate session when email confirmation is required", () => {
6
+ const result = resolveRegisterCompletionState({
7
+ ok: true,
8
+ requiresEmailConfirmation: true,
9
+ message: "Custom confirmation message"
10
+ });
11
+
12
+ assert.deepEqual(result, {
13
+ shouldCompleteLogin: false,
14
+ message: "Custom confirmation message"
15
+ });
16
+ });
17
+
18
+ test("resolveRegisterCompletionState falls back to default confirmation message", () => {
19
+ const result = resolveRegisterCompletionState({
20
+ ok: true,
21
+ requiresEmailConfirmation: true
22
+ });
23
+
24
+ assert.deepEqual(result, {
25
+ shouldCompleteLogin: false,
26
+ message: "Check your email to confirm the account before logging in."
27
+ });
28
+ });
29
+
30
+ test("resolveRegisterCompletionState completes login when confirmation is not required", () => {
31
+ const result = resolveRegisterCompletionState({
32
+ ok: true,
33
+ requiresEmailConfirmation: false
34
+ });
35
+
36
+ assert.deepEqual(result, {
37
+ shouldCompleteLogin: true,
38
+ message: ""
39
+ });
40
+ });