viur-ui 0.0.1

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 (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,627 @@
1
+ <template>
2
+ <div class="viur-login">
3
+ <!-- background image, tinted in the brand color (as in Admin4) -->
4
+ <div
5
+ v-if="state.background"
6
+ class="viur-login__background"
7
+ aria-hidden="true"
8
+ >
9
+ <img
10
+ :src="state.background"
11
+ alt=""
12
+ />
13
+ </div>
14
+
15
+ <div class="viur-login__card">
16
+ <slot name="header">
17
+ <img
18
+ v-if="state.logo"
19
+ class="viur-login__logo"
20
+ :src="state.logo"
21
+ alt=""
22
+ />
23
+ <h1 class="viur-login__title">{{ title }}</h1>
24
+ </slot>
25
+
26
+ <!-- checking for an existing session / completing a classic login -->
27
+ <div
28
+ v-if="checking || (state.view === 'done' && !store.isLoggedIn)"
29
+ class="viur-login__loading"
30
+ >
31
+ <wa-spinner></wa-spinner>
32
+ <span>{{ t("login.waiting") }}</span>
33
+ </div>
34
+
35
+ <template v-else-if="!store.isLoggedIn">
36
+ <!-- viur-core 3.9 answers classic (v1): /user/login offers the
37
+ providers and every further step is a form of its own whose url
38
+ the server hands out. Walked here like vi-vue-utils: provider
39
+ choice, password (+ "lost password"), second factor choice / OTP. -->
40
+ <template v-if="state.classic">
41
+ <wa-button
42
+ v-if="state.view !== 'start'"
43
+ appearance="plain"
44
+ size="small"
45
+ class="viur-login__back"
46
+ @click="showStart"
47
+ >
48
+ {{ t("login.back") }}
49
+ </wa-button>
50
+
51
+ <wa-callout
52
+ v-if="state.error"
53
+ variant="danger"
54
+ >
55
+ {{ t(state.error) }}
56
+ </wa-callout>
57
+
58
+ <template v-if="state.view === 'secondfactor'">
59
+ <template
60
+ v-for="(factor, index) in state.secondFactors"
61
+ :key="factor.target"
62
+ >
63
+ <div
64
+ v-if="index > 0"
65
+ class="viur-login__sep"
66
+ >
67
+ {{ t("login.or") }}
68
+ </div>
69
+ <wa-button @click="openStep(factor.target, 'otp')">{{ factor.label }}</wa-button>
70
+ </template>
71
+ </template>
72
+
73
+ <template v-else-if="state.step">
74
+ <!-- Enter submits; on keyup, so the bones have taken their value -->
75
+ <div @keyup.enter="submitClassic">
76
+ <ViForm
77
+ ref="viform"
78
+ :key="state.stepKey"
79
+ module="user"
80
+ :structure="state.step.structure"
81
+ :skel="state.step.skel"
82
+ :secure="true"
83
+ :use-categories="false"
84
+ :use-message-store="false"
85
+ label="placeholder"
86
+ />
87
+ </div>
88
+ <wa-button
89
+ variant="brand"
90
+ :loading="state.loading"
91
+ @click="submitClassic"
92
+ >
93
+ {{ t(CLASSIC_VIEWS[state.view].submit) }}
94
+ </wa-button>
95
+ <wa-button
96
+ v-if="state.view === 'start' && state.recoverUrl"
97
+ appearance="plain"
98
+ size="small"
99
+ class="viur-login__lost"
100
+ @click="openStep(state.recoverUrl, 'recover')"
101
+ >
102
+ {{ t("login.lost_password") }}
103
+ </wa-button>
104
+ </template>
105
+
106
+ <!-- providers without a form of their own -->
107
+ <template v-if="state.view === 'start'">
108
+ <template
109
+ v-for="(provider, index) in state.otherProviders"
110
+ :key="provider.target"
111
+ >
112
+ <div
113
+ v-if="index > 0 || state.step"
114
+ class="viur-login__sep"
115
+ >
116
+ {{ t("login.or") }}
117
+ </div>
118
+ <wa-button @click="chooseProvider(provider.target)">{{ provider.label }}</wa-button>
119
+ </template>
120
+ </template>
121
+ </template>
122
+
123
+ <!-- Otherwise the login flow is driven generically by the multistep
124
+ form: it GET-renders /user/login and walks the v2 status/follow
125
+ chain (chooser → userpassword → 2FA → success). -->
126
+ <ViForm
127
+ v-else
128
+ ref="viform"
129
+ module="user"
130
+ :action="action"
131
+ :secure="true"
132
+ multistep
133
+ :renderer="renderer ?? undefined"
134
+ :version="version ?? undefined"
135
+ :show-steps="showSteps"
136
+ :submit-label="t('login.login')"
137
+ @success="onFormSuccess"
138
+ @failed="onFailed"
139
+ >
140
+ <!-- Pass the form's own slots through so an app can supply its own
141
+ chrome (the default action bar is Shoelace; a WebAwesome app
142
+ replaces it here). Only forwarded when the consumer provides the
143
+ slot, otherwise ViForm keeps its default. -->
144
+ <template
145
+ v-if="$slots.actions"
146
+ #actions="actionProps"
147
+ >
148
+ <slot
149
+ name="actions"
150
+ v-bind="actionProps"
151
+ />
152
+ </template>
153
+ </ViForm>
154
+
155
+ <!-- Google is the one provider that isn't a form step (Identity SDK
156
+ token), so it lives beside the form. Only hidden in the later
157
+ classic steps, so the rendered button survives "back". -->
158
+ <template v-if="effectiveGoogleClientId">
159
+ <div
160
+ v-show="state.view === 'start'"
161
+ class="viur-login__sep"
162
+ >
163
+ {{ t("login.or") }}
164
+ </div>
165
+ <div
166
+ v-show="state.view === 'start'"
167
+ ref="googleBtn"
168
+ class="viur-login__google"
169
+ ></div>
170
+ <p
171
+ v-if="googleError"
172
+ v-show="state.view === 'start'"
173
+ class="viur-login__error"
174
+ >
175
+ {{ googleError }}
176
+ </p>
177
+ </template>
178
+ </template>
179
+ </div>
180
+ </div>
181
+ </template>
182
+
183
+ <script setup>
184
+ import { ref, reactive, computed, onMounted, nextTick } from "vue"
185
+ import { useI18n } from "vue-i18n"
186
+ import ViForm from "../forms/ViForm.vue"
187
+ import { useRouter } from "vue-router"
188
+ import { useUserStore } from "./store.js"
189
+ import { useConfigStore } from "../request/configStore.js"
190
+ import { normalizeStructure } from "../request/skel.js"
191
+ import { resetState } from "../request/request.js"
192
+ import { renderGoogleButton } from "./google.js"
193
+
194
+ /** Image setting → url, as the old admin resolved them: absolute urls stay,
195
+ * "/…" is a path on the backend, anything else an asset of the app. */
196
+ function assetUrl(value) {
197
+ if (!value || typeof value !== "string") return null
198
+ if (/^(https?:|data:|blob:|\/\/)/.test(value)) return value
199
+ if (value.startsWith("/")) return `${import.meta.env.VITE_API_URL ?? ""}${value}`
200
+ return `${import.meta.env.BASE_URL ?? "/"}${value}`
201
+ }
202
+
203
+ // classic chain (viur-core 3.9): submit label and failure message per view
204
+ const CLASSIC_VIEWS = {
205
+ start: { submit: "login.login", error: "login.login_error" },
206
+ provider: { submit: "login.login", error: "login.login_error" },
207
+ otp: { submit: "login.otp", error: "login.otp_error" },
208
+ recover: { submit: "login.recovery_start", error: "login.recovery_error" },
209
+ }
210
+
211
+ const props = defineProps({
212
+ title: { type: String, default: "Login" },
213
+ // Endpoint below the `user` module that starts the flow. Envelope v2 drives
214
+ // the whole chain from "login" (chooser → provider → 2FA). viur-core 3.9
215
+ // (classic v1) answers "login" with the provider choice; the screen then
216
+ // walks that chain itself, step by step, like vi-vue-utils.
217
+ action: { type: String, default: "login" },
218
+ // override the request renderer (default: VITE_DEFAULT_RENDERER || "json")
219
+ renderer: { type: String, default: null },
220
+ // optionally pin the protocol version (default null → unversioned, server
221
+ // aliases to the newest wire format / envelope v2)
222
+ version: { type: [String, Number, null], default: null },
223
+ // OAuth client id for "Sign in with Google". Omit to take the one the server
224
+ // advertises via /vi/config ("admin.user.google.clientID"); pass `false` to
225
+ // never show the Google button.
226
+ googleClientId: { type: [String, Boolean], default: null },
227
+ // Background image and logo of the app, used when the configuration sets
228
+ // none ("admin.login.background" / "admin.login.logo" are empty). Like
229
+ // those: absolute urls stay, "/…" is a path on the backend, anything else
230
+ // an asset of the app (below its base url).
231
+ backgroundImage: { type: String, default: null },
232
+ logo: { type: String, default: null },
233
+ // render the multistep progress bar
234
+ showSteps: { type: Boolean, default: false },
235
+ // check for an existing session on mount (and skip login if authenticated)
236
+ autoStart: { type: Boolean, default: true },
237
+ // Where to go once the session stands (also when `autoStart` finds an
238
+ // existing one). Routed through vue-router when the app has one, otherwise a
239
+ // plain location change. Omit to stay put and handle it in `@success`.
240
+ redirect: { type: String, default: null },
241
+ })
242
+ const emit = defineEmits(["success", "error"])
243
+
244
+ const store = useUserStore()
245
+ const configStore = useConfigStore()
246
+ const { t } = useI18n()
247
+ // Optional: ohne installierten Router ist das undefined — dann wird per
248
+ // location navigiert.
249
+ const router = useRouter()
250
+
251
+ const state = reactive({
252
+ classic: false, // viur-core 3.9: /user/login answered with the provider choice
253
+ view: "start", // "start" | "provider" | "otp" | "recover" | "secondfactor" | "done"
254
+ providers: [], // [{ name, target, label }] offered by /user/login
255
+ secondFactors: [], // [{ name, target, label }] offered after the password
256
+ step: null, // { structure, skel } of the form shown
257
+ stepKey: 0, // remounts the form per step
258
+ url: null, // where the shown step submits to
259
+ loading: false,
260
+ error: null, // i18n key of the failure message
261
+ passwordProvider: computed(() => state.providers.find((provider) => provider.name === "userpassword") ?? null),
262
+ // the recovery lives next to the password login (…/auth_userpassword/pwrecover)
263
+ recoverUrl: computed(() => state.passwordProvider?.target.replace(/\/login$/, "/pwrecover") ?? null),
264
+ otherProviders: computed(() =>
265
+ state.providers.filter((provider) => !["userpassword", "googleaccount"].includes(provider.name))
266
+ ),
267
+ // the configuration wins; empty there → the app's default
268
+ background: computed(() => assetUrl(configStore.get("login.background")) ?? assetUrl(props.backgroundImage)),
269
+ logo: computed(() => assetUrl(configStore.get("login.logo")) ?? assetUrl(props.logo)),
270
+ })
271
+
272
+ // explicit prop wins; `false` disables; otherwise the server config decides
273
+ const effectiveGoogleClientId = computed(() => {
274
+ if (props.googleClientId === false) return null
275
+ // core 3.9: only when /user/login offers the Google provider at all
276
+ if (state.classic && !state.providers.some((provider) => provider.name === "googleaccount")) return null
277
+ return props.googleClientId || configStore.googleClientId
278
+ })
279
+
280
+ const viform = ref(null)
281
+ const googleBtn = ref(null)
282
+ const checking = ref(true)
283
+ const googleError = ref("")
284
+
285
+ // after a login: admin/root unlock the module part of /vi/config, so refresh
286
+ // it for them (skipped for everyone else). Config is optional for the login
287
+ // itself, so a failure is swallowed.
288
+ function loadAdminConfig() {
289
+ return configStore.fetchConfig({ user: store.state.user }).catch(() => null)
290
+ }
291
+
292
+ async function onFormSuccess() {
293
+ try {
294
+ // the login resets the server session: skeys fetched before it are void
295
+ await resetState().catch(() => null)
296
+ await store.fetchSelf()
297
+ await loadAdminConfig()
298
+ } finally {
299
+ emit("success", store.state.user)
300
+ }
301
+ await goRedirect().catch(() => null)
302
+ }
303
+
304
+ function onFailed(error) {
305
+ emit("error", error)
306
+ }
307
+
308
+ /**
309
+ * Nach dem Anmelden weiterleiten, wenn `redirect` gesetzt ist. Läuft NACH dem
310
+ * success-Emit, damit der Aufrufer den Benutzer noch in seinen eigenen Zustand
311
+ * übernehmen kann (Rechte, Persona-Sichtbarkeit), bevor das Ziel rendert.
312
+ */
313
+ async function goRedirect() {
314
+ if (!props.redirect) return
315
+ if (router) {
316
+ await router.replace(props.redirect).catch(() => null)
317
+ return
318
+ }
319
+ window.location.assign(props.redirect)
320
+ }
321
+
322
+ // ── classic chain (viur-core 3.9) ──────────────────────────────────────────────
323
+
324
+ /**
325
+ * Options of a v1 choice (provider / second factor): the key is the url of the
326
+ * provider's first step, the value its label. `name` is the provider part of
327
+ * that url ("…/auth_userpassword/login" → "userpassword").
328
+ */
329
+ function selectOptions(body) {
330
+ const values = normalizeStructure(body?.["structure"] ?? {})?.["provider"]?.["values"] ?? {}
331
+ const options = Array.isArray(values) ? values : Object.entries(values)
332
+ return options.map(([target, label]) => ({
333
+ name: String(target).match(/\/(?:auth|f2)_([^/]+)\/[^/]+$/)?.[1] ?? String(target),
334
+ target: String(target),
335
+ label,
336
+ }))
337
+ }
338
+
339
+ /** Show a step's form. It submits to its `params.action_url` (second factor)
340
+ * or back to the url it was rendered from. */
341
+ function showStep(body, url, view) {
342
+ state.step = { structure: body?.["structure"] ?? {}, skel: body?.["data"] ?? {} }
343
+ state.url = body?.["params"]?.["action_url"] || url
344
+ state.view = view
345
+ state.stepKey++
346
+ }
347
+
348
+ async function openStep(url, view) {
349
+ state.error = null
350
+ state.loading = true
351
+ try {
352
+ showStep(await store.loadLoginStep(url), url, view)
353
+ } catch (error) {
354
+ state.error = CLASSIC_VIEWS[view]?.error ?? null
355
+ emit("error", error)
356
+ } finally {
357
+ state.loading = false
358
+ }
359
+ }
360
+
361
+ /** The beginning: the password form (when offered), the other providers as
362
+ * buttons, Google beside them. */
363
+ async function showStart() {
364
+ state.error = null
365
+ state.step = null
366
+ state.view = "start"
367
+ if (state.passwordProvider) return openStep(state.passwordProvider.target, "start")
368
+ }
369
+
370
+ /** Anything but the classic provider choice (an envelope v2, no answer at all)
371
+ * stays with the multistep form. */
372
+ async function startClassic() {
373
+ let body = null
374
+ try {
375
+ body = await store.loadLoginStep(props.action)
376
+ } catch {
377
+ return
378
+ }
379
+ if (!String(body?.["action"] ?? "").startsWith("select_authentication_provider")) return
380
+ state.classic = true
381
+ state.providers = selectOptions(body)
382
+ return showStart()
383
+ }
384
+
385
+ /** A provider without a widget of its own: the server confirms the choice and
386
+ * names its first step. */
387
+ async function chooseProvider(target) {
388
+ state.error = null
389
+ let body = null
390
+ try {
391
+ body = await store.selectAuthenticationProvider(target)
392
+ } catch (error) {
393
+ state.error = CLASSIC_VIEWS.provider.error
394
+ emit("error", error)
395
+ return
396
+ }
397
+ const url = body?.["next_url"] || target
398
+ // an external identity provider takes over the page
399
+ if (/^https?:\/\//.test(url)) {
400
+ window.location.assign(url)
401
+ return
402
+ }
403
+ return openStep(url, "provider")
404
+ }
405
+
406
+ /** Submit the shown step. ViForm sends it and takes over the field errors; the
407
+ * answer decides where the chain continues. */
408
+ async function submitClassic() {
409
+ if (state.loading || !viform.value) return
410
+ state.error = null
411
+ state.loading = true
412
+ try {
413
+ const result = await viform.value.sendData(state.url)
414
+ await onClassicAnswer(result?.data?.value)
415
+ } catch (error) {
416
+ // a string is ViForm's client-side check — the browser points at the field
417
+ if (typeof error !== "string") {
418
+ state.error = CLASSIC_VIEWS[state.view]?.error ?? null
419
+ emit("error", error)
420
+ }
421
+ } finally {
422
+ state.loading = false
423
+ }
424
+ }
425
+
426
+ async function onClassicAnswer(body) {
427
+ const action = body?.["action"]
428
+ if (action === "login_success") return finishClassic()
429
+ // new password is set — continue with the login
430
+ if (action === "pwrecover_success") return showStart()
431
+ if (action === "select_secondfactor_provider") {
432
+ state.secondFactors = selectOptions(body)
433
+ state.view = "secondfactor"
434
+ } else if (body?.["status"] === "rejected") {
435
+ // the form shows the field errors
436
+ state.error = CLASSIC_VIEWS[state.view]?.error ?? null
437
+ } else if (body?.["structure"]) {
438
+ // the next form came along: the second factor (only it carries an
439
+ // action_url) or the next recovery step
440
+ showStep(body, state.url, body?.["params"]?.["action_url"] ? "otp" : state.view)
441
+ }
442
+ }
443
+
444
+ async function finishClassic() {
445
+ state.view = "done"
446
+ try {
447
+ await onFormSuccess()
448
+ } finally {
449
+ // no session after all: back to the beginning instead of a dead end
450
+ if (!store.isLoggedIn) {
451
+ showStart()
452
+ mountGoogleButton()
453
+ }
454
+ }
455
+ }
456
+
457
+ async function onGoogleToken(token) {
458
+ googleError.value = ""
459
+ // loginWithGoogle resolves null on a transport error and never rejects
460
+ const body = await store.loginWithGoogle(token).catch(() => null)
461
+ if (!body) {
462
+ googleError.value = t("login.google_error")
463
+ emit("error", "google")
464
+ return
465
+ }
466
+ if (body.status === "success" || body.action === "login_success") {
467
+ await resetState().catch(() => null)
468
+ await configStore.fetchConfig({ user: store.state.user }).catch(() => null)
469
+ emit("success", store.state.user)
470
+ await goRedirect().catch(() => null)
471
+ return
472
+ }
473
+ // Google login needs a second factor → continue in the form flow. The
474
+ // classic chain hands that step over in the answer itself.
475
+ if (state.classic) return onClassicAnswer(body)
476
+ // loadStep manages its own loading/error state, so it need not be awaited.
477
+ if (body.follow && viform.value?.loadStep) {
478
+ viform.value.loadStep(body.follow)
479
+ }
480
+ }
481
+
482
+ async function mountGoogleButton() {
483
+ if (checking.value || store.isLoggedIn || !effectiveGoogleClientId.value) return
484
+ try {
485
+ await nextTick()
486
+ await renderGoogleButton(googleBtn.value, effectiveGoogleClientId.value, onGoogleToken)
487
+ } catch {
488
+ /* SDK failed to load — the form login still works */
489
+ }
490
+ }
491
+
492
+ onMounted(async () => {
493
+ store.configure({ renderer: props.renderer ?? undefined })
494
+ try {
495
+ // public settings (google client id, languages) BEFORE anything renders;
496
+ // without config the form login still works
497
+ if (!configStore.isLoaded) await configStore.fetchConfig().catch(() => null)
498
+ store.configure({ googleClientId: effectiveGoogleClientId.value })
499
+ if (props.autoStart) {
500
+ await store.fetchSelf()
501
+ if (store.isLoggedIn) {
502
+ await loadAdminConfig()
503
+ emit("success", store.state.user)
504
+ await goRedirect()
505
+ return
506
+ }
507
+ }
508
+ await startClassic()
509
+ await mountGoogleButton()
510
+ } finally {
511
+ checking.value = false
512
+ // button lives behind v-else-if; (re)mount it now that `checking` is false
513
+ if (!store.isLoggedIn) mountGoogleButton()
514
+ }
515
+ })
516
+ </script>
517
+
518
+ <style scoped>
519
+ .viur-login {
520
+ position: relative;
521
+ isolation: isolate;
522
+ box-sizing: border-box;
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: center;
526
+ width: 100%;
527
+ min-height: 100%;
528
+ padding: var(--wa-space-l, 1.5rem);
529
+ }
530
+
531
+ /* the whole viewport behind the card, also inside a padded / scrolling layout */
532
+ .viur-login__background {
533
+ position: fixed;
534
+ inset: 0;
535
+ z-index: -1;
536
+ }
537
+
538
+ .viur-login__background img {
539
+ display: block;
540
+ width: 100%;
541
+ height: 100%;
542
+ object-fit: cover;
543
+ }
544
+
545
+ .viur-login__background::after {
546
+ content: "";
547
+ position: absolute;
548
+ inset: 0;
549
+ opacity: 0.7;
550
+ background: linear-gradient(to top left, var(--wa-color-brand-30), var(--wa-color-brand-50));
551
+ }
552
+
553
+ .viur-login__logo {
554
+ align-self: center;
555
+ max-width: 100%;
556
+ height: 10rem;
557
+ object-fit: contain;
558
+ }
559
+
560
+ /* small screens: less padding, so the fields keep their width in the card */
561
+ @media (max-width: 530px) {
562
+ .viur-login {
563
+ padding: 0;
564
+ }
565
+
566
+ .viur-login__card {
567
+ padding: var(--wa-space-l, 1.5rem);
568
+ }
569
+
570
+ .viur-login__logo {
571
+ height: 8.75rem;
572
+ }
573
+ }
574
+
575
+ .viur-login__card {
576
+ display: flex;
577
+ flex-direction: column;
578
+ gap: var(--wa-space-m, 1rem);
579
+ width: 100%;
580
+ max-width: 24rem;
581
+ padding: var(--wa-space-xl, 2rem);
582
+ border-radius: var(--wa-border-radius-l, 0.75rem);
583
+ background: var(--wa-color-surface-default, #fff);
584
+ box-shadow: var(--wa-shadow-m, 0 4px 16px rgb(0 0 0 / 12%));
585
+ }
586
+
587
+ .viur-login__title {
588
+ margin: 0;
589
+ text-align: center;
590
+ font-size: 1.25rem;
591
+ }
592
+
593
+ .viur-login__loading {
594
+ display: flex;
595
+ align-items: center;
596
+ justify-content: center;
597
+ gap: var(--wa-space-s, 0.5rem);
598
+ padding: var(--wa-space-l, 1.5rem);
599
+ font-size: 1.5rem;
600
+ }
601
+
602
+ .viur-login__sep {
603
+ text-align: center;
604
+ color: var(--wa-color-text-quiet, #6b7280);
605
+ font-size: 0.85rem;
606
+ }
607
+
608
+ .viur-login__google {
609
+ display: flex;
610
+ justify-content: center;
611
+ }
612
+
613
+ .viur-login__back {
614
+ align-self: flex-start;
615
+ }
616
+
617
+ .viur-login__lost {
618
+ align-self: flex-end;
619
+ }
620
+
621
+ .viur-login__error {
622
+ margin: 0;
623
+ color: var(--wa-color-danger-fill-loud, #dc2626);
624
+ font-size: 0.85rem;
625
+ text-align: center;
626
+ }
627
+ </style>
@@ -0,0 +1,75 @@
1
+ // --------------------------------------------------------------------------- //
2
+ // Google Identity Services (GSI) helper //
3
+ // --------------------------------------------------------------------------- //
4
+ //
5
+ // Thin wrapper around Google's Identity SDK for the "Sign in with Google"
6
+ // provider (`auth_googleaccount`). Loads the SDK on demand, initializes it with
7
+ // the project's OAuth client id, and hands the returned ID token to a callback
8
+ // (the user store's loginWithGoogle). Only used when the login screen is given a
9
+ // googleClientId AND the server advertises the Google provider.
10
+
11
+ const GSI_SRC = "https://accounts.google.com/gsi/client"
12
+
13
+ let _loadPromise = null
14
+ let _initializedFor = null
15
+
16
+ /** Load the GSI client script once; resolves with window.google. */
17
+ export function loadGoogleSdk() {
18
+ if (window.google?.accounts?.id) return Promise.resolve(window.google)
19
+ if (_loadPromise) return _loadPromise
20
+
21
+ _loadPromise = new Promise((resolve, reject) => {
22
+ const script = document.createElement("script")
23
+ script.src = GSI_SRC
24
+ script.async = true
25
+ script.defer = true
26
+ script.dataset.viurGsi = "1"
27
+ script.addEventListener("load", () => resolve(window.google))
28
+ script.addEventListener("error", () => {
29
+ _loadPromise = null
30
+ reject(new Error("Failed to load the Google Identity SDK"))
31
+ })
32
+ document.head.appendChild(script)
33
+ })
34
+ return _loadPromise
35
+ }
36
+
37
+ /** Initialize GSI for a client id (idempotent per client id). `onCredential`
38
+ * receives the ID token string on a successful sign-in. Rejection (SDK load
39
+ * failure) propagates to the caller, which handles it. */
40
+ export async function initGoogle(clientId, onCredential) {
41
+ const google = await loadGoogleSdk()
42
+ if (_initializedFor !== clientId) {
43
+ google.accounts.id.initialize({
44
+ client_id: clientId,
45
+ ux_mode: "popup",
46
+ cancel_on_tap_outside: false,
47
+ callback: (response) => {
48
+ if (response?.credential) onCredential(response.credential)
49
+ },
50
+ })
51
+ _initializedFor = clientId
52
+ }
53
+ return google
54
+ }
55
+
56
+ /** Render the official Google button into `el`. */
57
+ export async function renderGoogleButton(el, clientId, onCredential, { theme = "outline", size = "large" } = {}) {
58
+ const google = await initGoogle(clientId, onCredential)
59
+ if (el) google.accounts.id.renderButton(el, { theme, size })
60
+ }
61
+
62
+ /** Trigger the One-Tap / popup prompt. */
63
+ export async function promptGoogle(clientId, onCredential) {
64
+ const google = await initGoogle(clientId, onCredential)
65
+ google.accounts.id.prompt()
66
+ }
67
+
68
+ /** Disable auto-select on logout so the next login asks again. */
69
+ export function revokeGoogle() {
70
+ try {
71
+ window.google?.accounts?.id?.disableAutoSelect?.()
72
+ } catch {
73
+ /* SDK not loaded — nothing to revoke */
74
+ }
75
+ }
@@ -0,0 +1,3 @@
1
+ export { useUserStore } from "./store.js"
2
+ export { default as LoginScreen } from "./LoginScreen.vue"
3
+ export { loadGoogleSdk, initGoogle, renderGoogleButton, promptGoogle, revokeGoogle } from "./google.js"