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.
- package/README.md +79 -0
- package/index.d.ts +351 -0
- package/index.js +46 -0
- package/package.json +84 -0
- package/src/components/Browser/Browser.vue +496 -0
- package/src/components/Browser/TreeNav.vue +408 -0
- package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
- package/src/components/Browser/ViurBrowser.vue +271 -0
- package/src/components/Browser/dragSession.ts +134 -0
- package/src/components/Browser/index.ts +14 -0
- package/src/components/Browser/types.ts +30 -0
- package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
- package/src/components/DataTable/DataTable.vue +3155 -0
- package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
- package/src/components/DataTable/ViurDataTable.vue +331 -0
- package/src/components/DataTable/columns.ts +400 -0
- package/src/components/DataTable/index.ts +60 -0
- package/src/components/DataTable/rows.ts +446 -0
- package/src/components/DataTable/types.ts +267 -0
- package/src/components/DataTable/useColumnLayout.ts +294 -0
- package/src/components/DataTable/useGridNavigation.ts +193 -0
- package/src/components/DataTable/useVirtualRows.ts +126 -0
- package/src/components/DataTable/viurColumns.ts +330 -0
- package/src/forms/ViForm.d.vue.ts +106 -0
- package/src/forms/ViForm.vue +592 -0
- package/src/forms/ViFormCategory.vue +57 -0
- package/src/forms/bones/Bone.vue +1023 -0
- package/src/forms/bones/BoneActions.vue +127 -0
- package/src/forms/bones/BoneLabel.vue +122 -0
- package/src/forms/bones/BoneMultipleList.vue +91 -0
- package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
- package/src/forms/bones/BoneNestedWrapper.vue +155 -0
- package/src/forms/bones/BoneSingleEntry.vue +51 -0
- package/src/forms/bones/FilterBone.vue +694 -0
- package/src/forms/bones/actionbar/defaultBar.vue +98 -0
- package/src/forms/bones/actionbar/fileBar.vue +165 -0
- package/src/forms/bones/actionbar/relationalBar.vue +125 -0
- package/src/forms/bones/actions/ActionDialog.vue +70 -0
- package/src/forms/bones/actions/Debug.vue +106 -0
- package/src/forms/bones/actions/Delete.vue +13 -0
- package/src/forms/bones/actions/DescribeImage.vue +201 -0
- package/src/forms/bones/actions/Download.vue +27 -0
- package/src/forms/bones/actions/Placeholder.vue +14 -0
- package/src/forms/bones/actions/Translation.vue +238 -0
- package/src/forms/bones/actions/index.js +280 -0
- package/src/forms/bones/bone.d.ts +45 -0
- package/src/forms/bones/country_information.json +779 -0
- package/src/forms/bones/index.js +225 -0
- package/src/forms/bones/uploadAdapter.js +46 -0
- package/src/forms/bones/utils.js +284 -0
- package/src/forms/bones/view/base_item.vue +35 -0
- package/src/forms/bones/view/boneLogic.js +202 -0
- package/src/forms/bones/view/index.js +5 -0
- package/src/forms/bones/widgets/booleanBone.vue +83 -0
- package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
- package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
- package/src/forms/bones/widgets/codeBone.vue +214 -0
- package/src/forms/bones/widgets/colorBone.vue +75 -0
- package/src/forms/bones/widgets/dateBone.vue +114 -0
- package/src/forms/bones/widgets/emailBone.vue +100 -0
- package/src/forms/bones/widgets/fileBone.vue +273 -0
- package/src/forms/bones/widgets/jsonBone.vue +63 -0
- package/src/forms/bones/widgets/keyBone.vue +71 -0
- package/src/forms/bones/widgets/numericBone.vue +125 -0
- package/src/forms/bones/widgets/passwordBone.vue +215 -0
- package/src/forms/bones/widgets/phoneBone.vue +173 -0
- package/src/forms/bones/widgets/rawBone.vue +94 -0
- package/src/forms/bones/widgets/recordBone.vue +64 -0
- package/src/forms/bones/widgets/relationalBone.vue +164 -0
- package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
- package/src/forms/bones/widgets/selectBone.vue +122 -0
- package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
- package/src/forms/bones/widgets/spatialBone.vue +111 -0
- package/src/forms/bones/widgets/stringBone.vue +139 -0
- package/src/forms/bones/widgets/textBone.vue +641 -0
- package/src/forms/install.d.ts +4 -0
- package/src/forms/install.js +43 -0
- package/src/forms/layouts/LayloutList2Columns.vue +57 -0
- package/src/forms/layouts/LayoutCategory.vue +38 -0
- package/src/forms/layouts/LayoutList.vue +31 -0
- package/src/forms/utils.js +601 -0
- package/src/public/assets/icons/fontawesome-manifest.json +8 -0
- package/src/public/assets/icons/regular/file.svg +1 -0
- package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
- package/src/public/assets/icons/solid/bug.svg +1 -0
- package/src/public/assets/icons/solid/check.svg +1 -0
- package/src/public/assets/icons/solid/circle-info.svg +1 -0
- package/src/public/assets/icons/solid/compress.svg +1 -0
- package/src/public/assets/icons/solid/download.svg +1 -0
- package/src/public/assets/icons/solid/expand.svg +1 -0
- package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
- package/src/public/assets/icons/solid/plus.svg +1 -0
- package/src/public/assets/icons/solid/question.svg +1 -0
- package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
- package/src/public/assets/icons/solid/upload.svg +1 -0
- package/src/public/assets/icons/solid/xmark.svg +1 -0
- package/src/request/cache.js +183 -0
- package/src/request/configStore.js +516 -0
- package/src/request/envelope.js +211 -0
- package/src/request/index.js +35 -0
- package/src/request/messageStore.js +201 -0
- package/src/request/request.js +752 -0
- package/src/request/skel.js +90 -0
- package/src/theme/colorStore.js +168 -0
- package/src/translations/de.js +178 -0
- package/src/translations/en.js +177 -0
- package/src/user/LoginScreen.d.vue.ts +54 -0
- package/src/user/LoginScreen.vue +627 -0
- package/src/user/google.js +75 -0
- package/src/user/index.js +3 -0
- package/src/user/store.d.ts +59 -0
- package/src/user/store.js +183 -0
- package/src/webawesomeCompat.css +72 -0
- 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
|
+
}
|