first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="login-page">
<div class="auth-container">
<div class="login-card">
<div class="brand-block">
<div class="brand-mark">
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
</div>
<h1 class="brand-title">Notely</h1>
</div>
<h2 class="auth-title">Login</h2>
<form @submit.prevent="handleLogin">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input id="email" v-model="form.email" type="email" class="form-control" required />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" v-model="form.password" type="password" class="form-control" required />
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<button type="submit" class="btn btn-primary w-100 auth-submit">Login</button>
</form>
<div v-if="providerLoginEnabled && providers.length" class="mt-4">
<div class="oauth-divider"><span>or continue with</span></div>
<div class="d-grid gap-2 mt-3">
<button v-for="provider in providers" :key="provider.id" type="button" class="btn btn-outline-dark auth-provider-btn" @click="startProviderLogin(provider.id)">
Sign in with {{ provider.name }}
</button>
</div>
</div>
<p v-if="registrationEnabled" class="text-center mt-4 mb-0 auth-switch-link">
Don't have an account?
<router-link to="/register">Register here</router-link>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/authStore";
import { useSettingsStore } from "../stores/settingsStore";
import apiClient from "../services/apiClient";
const router = useRouter();
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const form = ref({
email: "",
password: "",
});
const error = ref("");
const providers = ref([]);
const registrationEnabled = ref(true);
const providerLoginEnabled = ref(true);
const handleLogin = async () => {
error.value = "";
try {
await authStore.login(form.value.email, form.value.password);
router.push("/");
} catch (err) {
error.value = err;
}
};
const loadProviders = async () => {
try {
const response = await apiClient.get("/api/v1/auth/providers");
providers.value = response.data.providers || [];
} catch {
providers.value = [];
}
};
const startProviderLogin = (providerId) => {
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
};
const decodeBase64Url = (value) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
return atob(padded);
};
const decodeBase64UrlUTF8 = (value) => {
const binary = decodeBase64Url(value);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const readUserFromQuery = (params) => {
const plainUserJSON = params.get("user_json");
if (plainUserJSON) {
return JSON.parse(plainUserJSON);
}
const encodedUser = params.get("user");
if (encodedUser) {
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
}
return null;
};
const completeOAuthRedirect = async () => {
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
return true;
}
// Accept callback payloads even when `status` is missing.
if (status !== "oauth_success" && !accessToken) {
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
}
return false;
}
if (!accessToken) {
error.value = "Provider sign-in returned an incomplete session.";
return true;
}
try {
const user = readUserFromQuery(params);
if (!user) {
error.value = "Provider sign-in returned an incomplete session.";
return true;
}
authStore.setSession({ access_token: accessToken, user });
await router.replace("/");
} catch {
error.value = "Unable to restore the provider session.";
}
if (authStore.isAuthenticated) {
window.location.replace("/");
}
return true;
};
onMounted(async () => {
const flags = await settingsStore.loadFeatureFlags();
registrationEnabled.value = !!flags.registration_enabled;
providerLoginEnabled.value = !!flags.provider_login_enabled;
if (authStore.isAuthenticated) {
await router.replace("/");
return;
}
const handledOAuthCallback = await completeOAuthRedirect();
if (!handledOAuthCallback && providerLoginEnabled.value) {
await loadProviders();
}
const queryMessage = typeof router.currentRoute.value.query.message === "string" ? router.currentRoute.value.query.message : "";
if (!error.value && queryMessage) {
error.value = queryMessage;
}
});
</script>
<style scoped>
.login-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 460px;
}
.login-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-provider-btn {
min-height: 48px;
border-radius: 0.65rem;
}
.oauth-divider {
display: flex;
align-items: center;
color: #6c757d;
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid #dee2e6;
}
.oauth-divider span {
padding: 0 0.75rem;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.login-page {
padding: 0.85rem;
}
.login-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>