@jskit-ai/auth-web 0.1.4

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 (39) hide show
  1. package/package.descriptor.mjs +290 -0
  2. package/package.json +29 -0
  3. package/src/client/composables/useDefaultLoginView.js +935 -0
  4. package/src/client/composables/useDefaultSignOutView.js +113 -0
  5. package/src/client/index.js +19 -0
  6. package/src/client/lib/returnToPath.js +20 -0
  7. package/src/client/lib/surfaceLinkTarget.js +19 -0
  8. package/src/client/providers/AuthWebClientProvider.js +72 -0
  9. package/src/client/runtime/authGuardRuntime.js +499 -0
  10. package/src/client/runtime/authHttpClient.js +19 -0
  11. package/src/client/runtime/inject.js +43 -0
  12. package/src/client/runtime/tokens.js +7 -0
  13. package/src/client/runtime/useLoginView.js +7 -0
  14. package/src/client/runtime/useSignOut.js +121 -0
  15. package/src/client/views/AuthProfileMenuLinkItem.vue +83 -0
  16. package/src/client/views/AuthProfileWidget.vue +100 -0
  17. package/src/client/views/DefaultLoginView.vue +291 -0
  18. package/src/client/views/DefaultSignOutView.vue +58 -0
  19. package/src/server/constants/authActionIds.js +15 -0
  20. package/src/server/controllers/AuthController.js +183 -0
  21. package/src/server/providers/AuthRouteServiceProvider.js +31 -0
  22. package/src/server/providers/AuthWebServiceProvider.js +23 -0
  23. package/src/server/routes/authRoutes.js +244 -0
  24. package/src/server/services/AuthWebService.js +126 -0
  25. package/templates/src/pages/auth/login.vue +17 -0
  26. package/templates/src/pages/auth/signout.vue +17 -0
  27. package/templates/src/runtime/authGuardRuntime.js +7 -0
  28. package/templates/src/runtime/authHttpClient.js +1 -0
  29. package/templates/src/runtime/useSignOut.js +1 -0
  30. package/templates/src/views/auth/LoginView.vue +7 -0
  31. package/templates/src/views/auth/SignOutView.vue +7 -0
  32. package/test/authGuardRuntime.test.js +361 -0
  33. package/test/clientBoot.test.js +16 -0
  34. package/test/clientSurface.test.js +89 -0
  35. package/test/index.test.js +21 -0
  36. package/test/logoutFallback.test.js +50 -0
  37. package/test/providerRuntime.test.js +100 -0
  38. package/test/returnToPath.test.js +72 -0
  39. package/test/surfaceLinkTarget.test.js +80 -0
@@ -0,0 +1,83 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { mdiAccountCogOutline, mdiCogOutline, mdiLogin, mdiLogout } from "@mdi/js";
4
+ import {
5
+ useWebPlacementContext,
6
+ resolveSurfaceNavigationTargetFromPlacementContext
7
+ } from "@jskit-ai/shell-web/client/placement";
8
+
9
+ const props = defineProps({
10
+ label: {
11
+ type: String,
12
+ default: ""
13
+ },
14
+ to: {
15
+ type: String,
16
+ default: ""
17
+ },
18
+ icon: {
19
+ type: String,
20
+ default: ""
21
+ }
22
+ });
23
+ const { context: placementContext } = useWebPlacementContext();
24
+
25
+ const resolvedNavigationTarget = computed(() => {
26
+ const target = String(props.to || "").trim();
27
+ if (!target) {
28
+ return {
29
+ href: "",
30
+ sameOrigin: true
31
+ };
32
+ }
33
+
34
+ const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
35
+ path: target
36
+ });
37
+ return {
38
+ href: navigationTarget.href,
39
+ sameOrigin: navigationTarget.sameOrigin
40
+ };
41
+ });
42
+
43
+ const resolvedIcon = computed(() => {
44
+ const explicitIcon = String(props.icon || "").trim();
45
+ if (explicitIcon) {
46
+ return explicitIcon;
47
+ }
48
+
49
+ const normalizedLabel = String(props.label || "").trim().toLowerCase();
50
+ const normalizedTarget = String(props.to || "").trim().toLowerCase();
51
+ if (
52
+ normalizedLabel.includes("sign in") ||
53
+ normalizedTarget.includes("/auth/login")
54
+ ) {
55
+ return mdiLogin;
56
+ }
57
+
58
+ if (
59
+ normalizedLabel.includes("sign out") ||
60
+ normalizedTarget.includes("/auth/signout")
61
+ ) {
62
+ return mdiLogout;
63
+ }
64
+
65
+ if (normalizedLabel.includes("settings") || normalizedTarget.includes("/settings")) {
66
+ if (normalizedTarget.includes("/account")) {
67
+ return mdiAccountCogOutline;
68
+ }
69
+ return mdiCogOutline;
70
+ }
71
+
72
+ return "";
73
+ });
74
+ </script>
75
+
76
+ <template>
77
+ <v-list-item
78
+ :title="props.label || undefined"
79
+ :to="resolvedNavigationTarget.sameOrigin ? resolvedNavigationTarget.href || undefined : undefined"
80
+ :href="resolvedNavigationTarget.sameOrigin ? undefined : resolvedNavigationTarget.href || undefined"
81
+ :prepend-icon="resolvedIcon || undefined"
82
+ />
83
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
3
+ import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
4
+ import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
5
+ import { useAuthGuardRuntime } from "../runtime/inject.js";
6
+
7
+ const authGuardRuntime = useAuthGuardRuntime({
8
+ required: true
9
+ });
10
+ const authState = ref(authGuardRuntime.getState());
11
+ const { context: shellPlacementContext } = useWebPlacementContext();
12
+ let unsubscribe = null;
13
+
14
+ const shellUser = computed(() => {
15
+ const user = shellPlacementContext.value?.user;
16
+ if (!user || typeof user !== "object") {
17
+ return {};
18
+ }
19
+ return user;
20
+ });
21
+
22
+ const displayName = computed(() => {
23
+ const fromContext = String(shellUser.value.displayName || shellUser.value.name || "").trim();
24
+ if (fromContext) {
25
+ return fromContext;
26
+ }
27
+
28
+ const username = String(authState.value?.username || "").trim();
29
+ if (username) {
30
+ return username;
31
+ }
32
+
33
+ return "Guest";
34
+ });
35
+
36
+ const avatarUrl = computed(() => {
37
+ const value = String(shellUser.value.avatarUrl || shellUser.value.avatar || "").trim();
38
+ return value;
39
+ });
40
+
41
+ const initials = computed(() => {
42
+ const text = String(displayName.value || "").trim();
43
+ if (!text) {
44
+ return "G";
45
+ }
46
+
47
+ const words = text
48
+ .split(/\s+/)
49
+ .map((entry) => entry.trim())
50
+ .filter(Boolean);
51
+
52
+ if (words.length > 1) {
53
+ return `${words[0][0]}${words[1][0]}`.toUpperCase();
54
+ }
55
+
56
+ return text.slice(0, 2).toUpperCase();
57
+ });
58
+
59
+ const placementContext = computed(() => {
60
+ return {
61
+ auth: authState.value,
62
+ user: shellUser.value
63
+ };
64
+ });
65
+
66
+ onMounted(() => {
67
+ unsubscribe = authGuardRuntime.subscribe((nextState) => {
68
+ authState.value = nextState;
69
+ });
70
+ });
71
+
72
+ onBeforeUnmount(() => {
73
+ if (typeof unsubscribe === "function") {
74
+ unsubscribe();
75
+ unsubscribe = null;
76
+ }
77
+ });
78
+ </script>
79
+
80
+ <template>
81
+ <v-menu location="bottom end" offset="10">
82
+ <template #activator="{ props }">
83
+ <v-btn v-bind="props" variant="text" class="text-none pl-1 pr-2">
84
+ <v-avatar size="32" color="primary" variant="tonal">
85
+ <v-img v-if="avatarUrl" :src="avatarUrl" cover />
86
+ <span v-else class="text-caption font-weight-medium">{{ initials }}</span>
87
+ </v-avatar>
88
+ <span class="ml-2 d-none d-sm-inline text-body-2">{{ displayName }}</span>
89
+ </v-btn>
90
+ </template>
91
+
92
+ <v-list min-width="220" density="comfortable" class="py-1">
93
+ <ShellOutlet
94
+ host="auth-profile-menu"
95
+ position="primary-menu"
96
+ :context="placementContext"
97
+ />
98
+ </v-list>
99
+ </v-menu>
100
+ </template>
@@ -0,0 +1,291 @@
1
+ <template>
2
+ <v-main class="login-main">
3
+ <v-container class="fill-height d-flex align-center justify-center py-8">
4
+ <v-card class="auth-card" rounded="lg" elevation="1" border>
5
+ <v-card-text class="pa-7">
6
+ <div class="auth-header d-flex align-start justify-space-between ga-3 mb-5">
7
+ <div>
8
+ <p class="auth-kicker">Jskit Workspace</p>
9
+ <h1 class="auth-title">{{ authTitle }}</h1>
10
+ <p v-if="authSubtitle" class="text-medium-emphasis mb-0">{{ authSubtitle }}</p>
11
+ </div>
12
+ <v-chip color="primary" size="small" label>Secure</v-chip>
13
+ </div>
14
+
15
+ <div v-if="!isForgot && !isOtp" class="mode-switch d-flex ga-2 pa-1 mb-5">
16
+ <v-btn
17
+ data-testid="auth-mode-sign-in"
18
+ class="text-none"
19
+ :variant="isLogin ? 'flat' : 'text'"
20
+ :color="isLogin ? 'primary' : undefined"
21
+ @click="switchMode('login')"
22
+ >
23
+ Sign in
24
+ </v-btn>
25
+ <v-btn
26
+ v-if="!showRememberedAccount"
27
+ data-testid="auth-mode-register"
28
+ class="text-none"
29
+ :variant="isRegister ? 'flat' : 'text'"
30
+ :color="isRegister ? 'primary' : undefined"
31
+ @click="switchMode('register')"
32
+ >
33
+ Register
34
+ </v-btn>
35
+ </div>
36
+
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>
45
+ </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
+
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
+ />
118
+
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"
126
+ >
127
+ Send one-time code
128
+ </v-btn>
129
+ </div>
130
+
131
+ <div v-if="isLogin || isRegister" class="oauth-actions d-grid ga-2 mb-4">
132
+ <v-btn
133
+ v-for="provider in oauthProviders"
134
+ :key="provider.id"
135
+ 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)"
142
+ >
143
+ {{ oauthProviderButtonLabel(provider) }}
144
+ </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
+
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>
162
+
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>
167
+
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>
172
+ </v-form>
173
+ </v-card-text>
174
+ </v-card>
175
+ </v-container>
176
+ </v-main>
177
+ </template>
178
+
179
+ <script setup>
180
+ import { useDefaultLoginView } from "../composables/useDefaultLoginView.js";
181
+
182
+ const {
183
+ authTitle,
184
+ authSubtitle,
185
+ isForgot,
186
+ isOtp,
187
+ isLogin,
188
+ isRegister,
189
+ showRememberedAccount,
190
+ switchMode,
191
+ submitAuth,
192
+ rememberedAccountDisplayName,
193
+ rememberedAccountMaskedEmail,
194
+ rememberedAccountSwitchLabel,
195
+ switchAccount,
196
+ email,
197
+ emailErrorMessages,
198
+ emailTouched,
199
+ password,
200
+ showPassword,
201
+ passwordErrorMessages,
202
+ passwordTouched,
203
+ confirmPassword,
204
+ showConfirmPassword,
205
+ confirmPasswordErrorMessages,
206
+ confirmPasswordTouched,
207
+ otpCode,
208
+ otpCodeErrorMessages,
209
+ otpCodeTouched,
210
+ rememberAccountOnDevice,
211
+ otpRequestPending,
212
+ requestOtpCode,
213
+ oauthProviders,
214
+ loading,
215
+ oauthProviderIcon,
216
+ startOAuthSignIn,
217
+ oauthProviderButtonLabel,
218
+ canSubmit,
219
+ submitLabel
220
+ } = useDefaultLoginView();
221
+ </script>
222
+
223
+ <style scoped>
224
+ .login-main {
225
+ background-color: rgb(var(--v-theme-background));
226
+ background-image: radial-gradient(circle at 15% 12%, rgba(0, 107, 83, 0.12), transparent 32%);
227
+ }
228
+
229
+ .auth-card {
230
+ width: min(520px, 100%);
231
+ }
232
+
233
+ .auth-kicker {
234
+ margin: 0 0 8px;
235
+ font-size: 12px;
236
+ font-weight: 700;
237
+ letter-spacing: 0.09em;
238
+ text-transform: uppercase;
239
+ color: #395447;
240
+ }
241
+
242
+ .auth-title {
243
+ margin: 0 0 8px;
244
+ font-size: clamp(28px, 3vw, 32px);
245
+ line-height: 1.2;
246
+ color: #1d2c24;
247
+ }
248
+
249
+ .mode-switch {
250
+ border-radius: 12px;
251
+ background-color: rgba(57, 84, 71, 0.08);
252
+ }
253
+
254
+ .remembered-account {
255
+ border-radius: 12px;
256
+ border: 1px solid rgba(57, 84, 71, 0.2);
257
+ background: rgba(57, 84, 71, 0.07);
258
+ padding: 12px 14px;
259
+ }
260
+
261
+ .remembered-copy {
262
+ min-width: 0;
263
+ }
264
+
265
+ .remembered-title {
266
+ margin: 0;
267
+ font-size: 14px;
268
+ font-weight: 600;
269
+ color: #1d2c24;
270
+ }
271
+
272
+ .remembered-email {
273
+ margin: 2px 0 0;
274
+ font-size: 12px;
275
+ color: rgba(29, 44, 36, 0.72);
276
+ }
277
+
278
+ .oauth-provider-button {
279
+ justify-content: center;
280
+ }
281
+
282
+ @media (max-width: 959px) {
283
+ .auth-content {
284
+ padding: 24px 20px;
285
+ }
286
+
287
+ .auth-title {
288
+ font-size: 26px;
289
+ }
290
+ }
291
+ </style>
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <v-main class="signout-main">
3
+ <v-container class="fill-height d-flex align-center justify-center py-8">
4
+ <v-card class="signout-card" rounded="lg" elevation="1" border>
5
+ <v-card-text class="pa-7">
6
+ <h1 class="text-h5 mb-3">Signing you out</h1>
7
+
8
+ <p v-if="status === 'pending'" class="text-medium-emphasis mb-4">
9
+ Please wait while we end your session.
10
+ </p>
11
+
12
+ <p v-if="status === 'error'" class="text-body-1 text-medium-emphasis mb-4">
13
+ {{ errorMessage || "Sign out failed. Please try again." }}
14
+ </p>
15
+
16
+ <div class="d-flex ga-3">
17
+ <v-btn
18
+ v-if="status === 'error'"
19
+ color="primary"
20
+ variant="flat"
21
+ class="text-none"
22
+ @click="retrySignOut"
23
+ >
24
+ Retry sign out
25
+ </v-btn>
26
+ <v-btn
27
+ v-if="status === 'error'"
28
+ color="secondary"
29
+ variant="text"
30
+ class="text-none"
31
+ @click="goToLogin"
32
+ >
33
+ Go to login
34
+ </v-btn>
35
+ <v-progress-circular v-if="status === 'pending'" indeterminate color="primary" size="22" />
36
+ </div>
37
+ </v-card-text>
38
+ </v-card>
39
+ </v-container>
40
+ </v-main>
41
+ </template>
42
+
43
+ <script setup>
44
+ import { useDefaultSignOutView } from "../composables/useDefaultSignOutView.js";
45
+
46
+ const { status, errorMessage, retrySignOut, goToLogin } = useDefaultSignOutView();
47
+ </script>
48
+
49
+ <style scoped>
50
+ .signout-main {
51
+ background-color: rgb(var(--v-theme-background));
52
+ background-image: radial-gradient(circle at 15% 12%, rgba(0, 107, 83, 0.12), transparent 32%);
53
+ }
54
+
55
+ .signout-card {
56
+ width: min(520px, 100%);
57
+ }
58
+ </style>
@@ -0,0 +1,15 @@
1
+ const AUTH_ACTION_IDS = Object.freeze({
2
+ REGISTER: "auth.register",
3
+ LOGIN_PASSWORD: "auth.login.password",
4
+ LOGIN_OTP_REQUEST: "auth.login.otp.request",
5
+ LOGIN_OTP_VERIFY: "auth.login.otp.verify",
6
+ LOGIN_OAUTH_START: "auth.login.oauth.start",
7
+ LOGIN_OAUTH_COMPLETE: "auth.login.oauth.complete",
8
+ LOGOUT: "auth.logout",
9
+ SESSION_READ: "auth.session.read",
10
+ PASSWORD_RESET_REQUEST: "auth.password.reset.request",
11
+ PASSWORD_RECOVERY_COMPLETE: "auth.password.recovery.complete",
12
+ PASSWORD_RESET: "auth.password.reset"
13
+ });
14
+
15
+ export { AUTH_ACTION_IDS };