aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.com>2025-08-15 19:20:48 +0100
committerLeonardo Bishop <me@leonardobishop.com>2025-08-15 19:20:48 +0100
commit8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (patch)
tree7b4f203d92f4b99b1e98fac314415e293984196b /web
parent4697556cac819c47d068819b9fc9c3b4ea84e279 (diff)
Implement OIDC
Diffstat (limited to 'web')
-rw-r--r--web/composables/fetch-favourites.ts2
-rw-r--r--web/composables/fetch-login.ts25
-rw-r--r--web/composables/fetch-schedule.ts7
-rw-r--r--web/nuxt.config.ts2
-rw-r--r--web/pages/agenda.vue5
-rw-r--r--web/pages/login.vue193
-rw-r--r--web/pages/login/[[provider]].vue302
-rw-r--r--web/stores/login-options.ts22
-rw-r--r--web/web.go16
9 files changed, 366 insertions, 208 deletions
diff --git a/web/composables/fetch-favourites.ts b/web/composables/fetch-favourites.ts
index 97b443a..e586d5b 100644
--- a/web/composables/fetch-favourites.ts
+++ b/web/composables/fetch-favourites.ts
@@ -1,4 +1,4 @@
-export default function useFetchFavourites() {
+export default function() {
const favouritesStore = useFavouritesStore();
const errorStore = useErrorStore();
const config = useRuntimeConfig();
diff --git a/web/composables/fetch-login.ts b/web/composables/fetch-login.ts
new file mode 100644
index 0000000..707a04f
--- /dev/null
+++ b/web/composables/fetch-login.ts
@@ -0,0 +1,25 @@
+import { useLoginOptionsStore } from "~/stores/login-options";
+
+export default function() {
+ const loginOptionsStore = useLoginOptionsStore();
+ const errorStore = useErrorStore();
+ const config = useRuntimeConfig();
+
+ loginOptionsStore.setStatus('pending')
+
+ $fetch(config.public.baseURL + '/login', {
+ method: 'GET',
+ server: false,
+ lazy: true,
+ onResponse: ({ response }) => {
+ if (!response.ok) {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ }
+
+ if (response._data) {
+ loginOptionsStore.setLoginOptions((response._data as any).data.options);
+ loginOptionsStore.setStatus('idle')
+ }
+ },
+ });
+} \ No newline at end of file
diff --git a/web/composables/fetch-schedule.ts b/web/composables/fetch-schedule.ts
index c061d92..a0e6fec 100644
--- a/web/composables/fetch-schedule.ts
+++ b/web/composables/fetch-schedule.ts
@@ -1,4 +1,4 @@
-export default function useFetchFavourites() {
+export default function() {
const scheduleStore = useScheduleStore();
const errorStore = useErrorStore();
const config = useRuntimeConfig();
@@ -10,7 +10,7 @@ export default function useFetchFavourites() {
onResponse: ({ response }) => {
if (!response.ok) {
if (response.status === 401) {
- navigateTo({ name: 'login', state: { error: 'Sorry, your session has expired' } });
+ navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } });
} else {
errorStore.setError(response._data.message || 'An unknown error occurred');
}
@@ -18,9 +18,6 @@ export default function useFetchFavourites() {
if (response._data) {
scheduleStore.setSchedule((response._data as any).data.schedule);
- errorStore.setError("Schedule set");
- } else {
- errorStore.setError("Invalid response returned by server");
}
},
});
diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts
index dc76af0..d5762e7 100644
--- a/web/nuxt.config.ts
+++ b/web/nuxt.config.ts
@@ -15,7 +15,7 @@ try {
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
- ssr: true,
+ ssr: false,
css: ["~/assets/css/main.css"],
runtimeConfig: {
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>
diff --git a/web/stores/login-options.ts b/web/stores/login-options.ts
new file mode 100644
index 0000000..fd97a75
--- /dev/null
+++ b/web/stores/login-options.ts
@@ -0,0 +1,22 @@
+import { defineStore } from "pinia";
+
+interface LoginOption {
+ name: string;
+ identifier: string;
+ type: string;
+}
+
+export const useLoginOptionsStore = defineStore('loginOptions', () => {
+ const loginOptions = ref([] as LoginOption[])
+ const status = ref('idle' as 'idle' | 'pending')
+
+ const setLoginOptions = (newLoginOptions: LoginOption[]) => {
+ loginOptions.value = newLoginOptions
+ }
+
+ const setStatus = (newStatus: 'idle' | 'pending') => {
+ status.value = newStatus
+ }
+
+ return {loginOptions, status, setLoginOptions, setStatus}
+})
diff --git a/web/web.go b/web/web.go
index 81ed6be..d823d46 100644
--- a/web/web.go
+++ b/web/web.go
@@ -4,25 +4,33 @@ import (
"embed"
"io/fs"
"net/http"
+ "regexp"
)
-//go:generate npm ci
+//go:generate npm install
//go:generate npm run generate
//go:embed all:.output/public
var fsys embed.FS
+var urlFileRegexp = regexp.MustCompile(`[\w\-/]+\.[a-zA-Z]+$`)
type WebFileServer struct {
- server http.Handler
+ root fs.FS
+ handler http.Handler
}
func NewWebFileServer() *WebFileServer {
fsys, _ := fs.Sub(fsys, ".output/public")
return &WebFileServer{
- server: http.FileServerFS(fsys),
+ root: fsys,
+ handler: http.FileServerFS(fsys),
}
}
func (fs *WebFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- fs.server.ServeHTTP(w, r)
+ if p := r.URL.Path; p != "/" && !urlFileRegexp.MatchString(p) {
+ http.ServeFileFS(w, r, fs.root, "index.html")
+ return
+ }
+ fs.handler.ServeHTTP(w, r)
}