first commit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user