diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-08-15 19:20:48 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-08-15 19:20:48 +0100 |
| commit | 8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (patch) | |
| tree | 7b4f203d92f4b99b1e98fac314415e293984196b /web/pages | |
| parent | 4697556cac819c47d068819b9fc9c3b4ea84e279 (diff) | |
Implement OIDC
Diffstat (limited to 'web/pages')
| -rw-r--r-- | web/pages/agenda.vue | 5 | ||||
| -rw-r--r-- | web/pages/login.vue | 193 | ||||
| -rw-r--r-- | web/pages/login/[[provider]].vue | 302 |
3 files changed, 303 insertions, 197 deletions
diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue index 5e0c643..9b55c9b 100644 --- a/web/pages/agenda.vue +++ b/web/pages/agenda.vue @@ -14,9 +14,6 @@ const favouriteEvents = computed(() => { const calendarStatus = ref('pending' as 'pending' | 'idle'); const calendarLink = ref('') -const calendarLinkWithPageProtocol = computed(() => { - return window.location.protocol + '//' + calendarLink.value; -}); const refConfirmDeleteDialog = ref<typeof Dialog>(); @@ -97,7 +94,7 @@ function deleteCalendar() { <div v-else-if="calendarStatus === 'idle'" class="calendar"> <template v-if="calendarLink"> <span>You can add your agenda to your own calendar using the iCal link below</span> - <Input :value="calendarLinkWithPageProtocol" readonly/> + <Input :value="calendarLink" readonly/> <Button @click="refConfirmDeleteDialog!.show()" :loading="calendarAction">Delete calendar</Button> </template> <template v-else> diff --git a/web/pages/login.vue b/web/pages/login.vue deleted file mode 100644 index 2900a5e..0000000 --- a/web/pages/login.vue +++ /dev/null @@ -1,193 +0,0 @@ -<script setup lang="ts"> -import { ref } from 'vue' -import { FetchError } from 'ofetch' -import Input from '~/components/Input.vue' - -definePageMeta({ - layout: 'none' -}) - -const isLoading = ref(false) -const error = ref("") - -const config = useRuntimeConfig() -const headers = useRequestHeaders(['cookie']) - -const handleSubmit = async (e: Event) => { - const target = e.target as HTMLFormElement; - const formData = new FormData(target); - - isLoading.value = true - error.value = "" - - try { - await $fetch(config.public.baseURL + '/login', { - method: 'POST', - body: JSON.stringify(Object.fromEntries(formData)), - headers: headers, - server: false, - }); - - navigateTo("/"); - } catch (e: any) { - if ((e as FetchError).data) { - error.value = e.data.message - } else { - error.value = "An unknown error occurred" - } - } - - isLoading.value = false -} - -onMounted(() => { - if (history.state.error) { - error.value = history.state.error as string - } -}) - -</script> - -<template> - <div class="auth-container"> - <div class="auth-header"> - <h2 class="auth-title">Sign in</h2> - - <div v-if="error" class="auth-error"> - {{ error }} - </div> - </div> - - <div class="auth-body"> - <Panel> - <form class="auth-form" @submit.prevent="handleSubmit"> - <div class="form-group"> - <label for="username" class="form-label"> - Username - </label> - <div class="form-input-container"> - <Input id="username" name="username" required /> - </div> - </div> - - <div class="form-group"> - <label for="password" class="form-label"> - Password - </label> - <div class="form-input-container"> - <Input id="password" name="password" type="password" autocomplete="current-password" required /> - </div> - </div> - - - <div class="form-submit"> - <Button type="submit" :loading="isLoading"> - Sign in - </Button> - </div> - - <Version class="version" /> - </form> - </Panel> - - </div> - - <div class="form-footer"> - <NuxtLink to="/register" class="register-link"> - Register - </NuxtLink> - </div> - - </div> -</template> - -<style scoped> -div.auth-container { - min-height: 100vh; - background-color: var(--color-background-muted); - display: flex; - flex-direction: column; - justify-content: center; - gap: 1rem; -} - -div.auth-header { - margin: 0 auto; - width: 100%; - max-width: 28rem; - display: flex; - gap: 1rem; - align-items: center; - flex-direction: column; -} - -h2.auth-title { - margin-top: 1.5rem; - font-size: 1.875rem; - font-weight: 800; - color: #1f2937; -} - -div.auth-body { - margin-top: 2rem; - margin: 0 auto; - width: 100%; - max-width: 28rem; -} - -form.auth-form { - display: grid; - gap: 1.5rem; -} - -div.auth-error { - color: var(--color-text-error); - font-style: oblique; -} - -div.form-group { - display: flex; - flex-direction: column; -} - -label.form-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: #374151; -} - -div.form-input-container { - margin-top: 0.25rem; -} - -div.form-footer { - display: flex; - justify-content: flex-end; - margin: 0 auto; - max-width: 28rem; -} - -div.form-submit { - display: flex; -} - -div.form-submit button { - width: 100%; -} - -.version { - font-size: var(--text-smaller); - margin: 0 auto; - color: var(--color-text-muted-light); -} - -.register-link { - font-size: var(--text-small); - font-weight: 500; -} - -input[name="username"] { - text-transform: lowercase; -} -</style> diff --git a/web/pages/login/[[provider]].vue b/web/pages/login/[[provider]].vue new file mode 100644 index 0000000..bfc7e69 --- /dev/null +++ b/web/pages/login/[[provider]].vue @@ -0,0 +1,302 @@ +<script setup lang="ts"> +import { ref } from 'vue' +import { FetchError } from 'ofetch' +import Input from '~/components/Input.vue' +import { useLoginOptionsStore } from '~/stores/login-options' + +definePageMeta({ + layout: 'none' +}) + +const authenticating = ref(false) +const authenticatingProvider = ref('') +const error = ref("") +const completingJourney = ref(false) +const basicAuthEnabled = ref(false) + +const route = useRoute() +const config = useRuntimeConfig() +const loginOptionsStore = useLoginOptionsStore() +const headers = useRequestHeaders(['cookie']) + +const { loginOptions, status } = storeToRefs(loginOptionsStore) + +watch(loginOptions, (options) => { + basicAuthEnabled.value = options.some(o => o.type === 'basic') +}) + +const handleBasicAuth = async (e: Event, providerName: string) => { + const target = e.target as HTMLFormElement; + const formData = new FormData(target); + + authenticating.value = true + authenticatingProvider.value = providerName + + try { + await $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + headers: headers, + server: false, + }); + + navigateTo("/"); + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' + } +} + +const handleOIDCAuth = async (providerName: string) => { + authenticating.value = true + authenticatingProvider.value = providerName + + try { + let response: any = await $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + headers: headers, + server: false, + }); + navigateTo(response.data.url, { external: true }) + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' + } +} + +onMounted(async () => { + if (history.state.error) { + error.value = history.state.error as string + } + + if (route.params.provider) { + completingJourney.value = true + + try { + let state = route.query.state + let code = route.query.code + + + let response: any = await $fetch(config.public.baseURL + '/login/' + route.params.provider, { + method: 'POST', + headers: headers, + server: false, + body: { + state: state, + code: code, + } + }); + + if (response.code === 307) { + throw Error() + } + + navigateTo("/"); + } catch (e: any) { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + completingJourney.value = false + fetchLogin() + } + return + } + + fetchLogin() +}) + +</script> + +<template> + <div class="auth-container"> + <div class="auth-header"> + <h2 class="auth-title">Sign in</h2> + + <div v-if="error" class="auth-error"> + {{ error }} + </div> + </div> + + <div class="auth-body"> + <Panel> + <div class="auth-form"> + <div v-if="completingJourney" class="spinner"> + <Spinner color="var(--color-text-muted)" />Completing login... + </div> + <div v-if="status === 'pending'" class="spinner"> + <Spinner color="var(--color-text-muted)" />Getting login options... + </div> + <div v-for="option in loginOptions"> + <form v-if="option.type === 'basic'" class="basic-form" @submit.prevent="(e) => handleBasicAuth(e, option.identifier)"> + <div class="form-group"> + <label for="username" class="form-label"> + Username + </label> + <div class="form-input-container"> + <Input id="username" name="username" required /> + </div> + </div> + + <div class="form-group"> + <label for="password" class="form-label"> + Password + </label> + <div class="form-input-container"> + <Input id="password" name="password" type="password" autocomplete="current-password" required /> + </div> + </div> + + + <div class="form-submit"> + <Button type="submit" :loading="authenticatingProvider === option.identifier" :disabled="authenticating"> + Sign in + </Button> + </div> + </form> + + <div v-if="option.type === 'oidc'" class="auth-provider"> + <Button type="button" :loading="authenticatingProvider === option.identifier" :disabled="authenticating" @click="(e) => handleOIDCAuth(option.identifier)"> + Sign in with {{ option.name }} + </Button> + </div> + </div> + + <Version class="version" /> + </div> + </Panel> + + </div> + + <div v-if="basicAuthEnabled" class="form-footer"> + <NuxtLink to="/register" class="register-link"> + Register + </NuxtLink> + </div> + + </div> +</template> + +<style scoped> +div.auth-container { + min-height: 100vh; + background-color: var(--color-background-muted); + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; +} + +div.auth-header { + margin: 0 auto; + width: 100%; + max-width: 28rem; + display: flex; + gap: 1rem; + align-items: center; + flex-direction: column; +} + +h2.auth-title { + margin-top: 1.5rem; + font-size: 1.875rem; + font-weight: 800; + color: #1f2937; +} + +div.auth-body { + margin-top: 2rem; + margin: 0 auto; + width: 100%; + max-width: 28rem; +} + +div.auth-form { + display: grid; + gap: 1.5rem; +} + +form.basic-form { + display: grid; + gap: 1.5rem; +} + +div.auth-error { + color: var(--color-text-error); + font-style: oblique; +} + +div.form-group { + display: flex; + flex-direction: column; +} + +label.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +div.form-input-container { + margin-top: 0.25rem; +} + +div.form-footer { + display: flex; + justify-content: flex-end; + margin: 0 auto; + max-width: 28rem; +} + +div.form-submit { + display: flex; +} + +div.form-submit button { + width: 100%; +} + +.version { + font-size: var(--text-smaller); + margin: 0 auto; + color: var(--color-text-muted-light); +} + +.auth-provider button { + display: flex; + width: 100%; +} + +.register-link { + font-size: var(--text-small); + font-weight: 500; +} + +input[name="username"] { + text-transform: lowercase; +} + +.spinner { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: var(--text-normal); + color: var(--color-text-muted); +} +</style> |
