Files
clipperia/src/routes/auth/Login.vue
2025-11-02 20:39:57 -03:00

443 lines
10 KiB
Vue

<template>
<q-page class="login-page">
<div class="login-page__container">
<section class="login-page__hero">
<div class="login-page__hero-content">
<div class="login-page__brand">
<q-avatar size="48px" color="white" text-color="primary"
>CI</q-avatar
>
<div>
<div class="login-page__brand-title">Clipper IA</div>
<div class="login-page__brand-subtitle">
Cortes automáticos com inteligência
</div>
</div>
</div>
<h2>Produza cortes envolventes sem perder tempo na edição.</h2>
<p>
Envie a url do vídeo no Youtube e receba os melhores momentos
prontos para divulgar em minutos. Nossa IA identifica picos de
retenção, fala e emoção para gerar clipes certeiros.
</p>
<div class="login-page__stats">
<div>
<span class="login-page__stat-value">+3x</span>
<span class="login-page__stat-label">mais alcance</span>
</div>
<div>
<span class="login-page__stat-value">24/7</span>
<span class="login-page__stat-label">operando</span>
</div>
</div>
</div>
</section>
<section class="login-card__wrapper">
<q-card flat bordered class="login-card">
<div class="login-card__header">
<div class="login-card__badge">
<q-icon name="sym_o_bolt" size="20px" />
<span>Beta exclusivo</span>
</div>
<h1>Bem-vindo de volta</h1>
<p>
Entre para continuar gerando clipes automaticamente com o Clipper
IA.
</p>
</div>
<q-form class="login-card__form">
<TextField
v-model="username"
label="Email ou usuário"
placeholder="seuemail@clipper.ai"
required
>
<template #prepend>
<q-icon name="sym_o_person" color="primary" />
</template>
</TextField>
<TextField
v-model="password"
:type="showPassword ? 'text' : 'password'"
label="Senha"
required
placeholder="••••••••"
>
<template #prepend>
<q-icon name="sym_o_lock" color="primary" />
</template>
<template #append>
<q-icon
:name="
showPassword ? 'sym_o_visibility_off' : 'sym_o_visibility'
"
class="cursor-pointer"
color="primary"
@click="showPassword = !showPassword"
/>
</template>
</TextField>
<div class="login-card__links">
<Button
variant="link"
color="primary"
text-color="primary"
label="Esqueci minha senha"
@click.prevent="handleForgotPassword"
/>
</div>
<Button
type="submit"
label="Entrar"
:loading="loading"
@click="handleLogin"
full-width
/>
</q-form>
<p v-if="error" class="login-card__error">{{ error }}</p>
</q-card>
</section>
</div>
</q-page>
</template>
<script>
import Cookies from "js-cookie";
import Button from "@components/Button";
import TextField from "@components/TextField";
import { API } from "@config/axios";
import {
buildUserProfileFromToken,
extractRolesFromToken,
} from "@utils/keycloak";
export default {
name: "LoginView",
components: {
Button,
TextField,
},
data() {
return {
username: "",
password: "",
showPassword: false,
error: "",
loading: false,
};
},
methods: {
async handleLogin() {
if (!this.username || !this.password) {
this.error = "Por favor, preencha todos os campos";
return;
}
this.error = "";
try {
this.loading = true;
const params = new URLSearchParams();
params.append("username", this.username.trim());
params.append("password", this.password);
const { data } = await API.post("/auth/login", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const accessTokenDays = data.expires_in
? data.expires_in / (60 * 60 * 24)
: undefined;
const refreshTokenDays = data.refresh_expires_in
? data.refresh_expires_in / (60 * 60 * 24)
: undefined;
const cookieOptions = {
sameSite: "lax",
secure: window.location.protocol === "https:",
path: "/",
};
Cookies.set("token", data.access_token, {
...cookieOptions,
expires: accessTokenDays,
});
Cookies.set("refresh_token", data.refresh_token, {
...cookieOptions,
expires: refreshTokenDays,
});
const profile = buildUserProfileFromToken(data.access_token);
const roles = profile?.roles?.length
? profile.roles
: extractRolesFromToken(data.access_token);
if (roles?.length) {
Cookies.set("user_roles", JSON.stringify(roles), {
...cookieOptions,
expires: accessTokenDays,
});
}
if (profile) {
Cookies.set("user_profile", JSON.stringify(profile), {
...cookieOptions,
expires: accessTokenDays,
});
}
await this.$router.push("/videos");
} catch (error) {
console.log("ok");
this.$q.notify({
type: "negative",
message: "Não foi possível entrar. Verifique as credenciais.",
});
} finally {
this.loading = false;
}
},
handleForgotPassword() {
this.$q.notify({
type: "info",
message: "Entre em contato com o suporte para redefinir sua senha.",
});
},
handleSupport() {
this.$q.notify({
type: "info",
message:
"Envie um email para suporte@clipper.ai e responderemos rapidamente.",
});
},
},
};
</script>
<style scoped lang="scss">
.login-page {
height: 100vh;
display: flex;
align-items: stretch;
justify-content: center;
background: radial-gradient(
circle at top left,
rgba(127, 86, 217, 0.18),
transparent 55%
),
radial-gradient(
circle at bottom right,
rgba(83, 56, 158, 0.22),
transparent 45%
),
#f5f6fa;
}
.login-page__container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
width: min(1100px, 92vw);
gap: clamp(32px, 6vw, 56px);
align-items: center;
padding: clamp(32px, 5vw, 64px) 0;
}
.login-page__hero {
color: #f8f9ff;
background: linear-gradient(160deg, #53389e 0%, #7f56d9 60%, #b692f6 100%);
border-radius: 32px;
padding: clamp(32px, 5vw, 56px);
box-shadow: 0 24px 64px rgba(83, 56, 158, 0.35);
}
.login-page__hero h2 {
font-size: clamp(28px, 3vw, 36px);
font-weight: 700;
margin: 32px 0 16px;
line-height: 1.2;
}
.login-page__hero p {
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.82);
margin-bottom: 36px;
}
.login-page__brand {
display: flex;
align-items: center;
gap: 16px;
}
.login-page__brand-title {
font-size: 18px;
font-weight: 600;
}
.login-page__brand-subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.72);
}
.login-page__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.login-page__stat-value {
display: block;
font-size: 28px;
font-weight: 700;
}
.login-page__stat-label {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.login-card__wrapper {
display: flex;
justify-content: center;
}
.login-card {
width: 100%;
border-radius: 24px;
padding: clamp(28px, 4vw, 40px);
box-shadow: 0 22px 40px rgba(16, 24, 40, 0.12);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.92);
}
.login-card__header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.login-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(127, 86, 217, 0.12);
color: #53389e;
font-weight: 600;
}
.login-card__header h1 {
font-size: clamp(24px, 3vw, 30px);
font-weight: 700;
margin: 0;
color: #101828;
}
.login-card__header p {
margin: 0;
color: #475467;
font-size: 16px;
}
.login-card__form {
display: flex;
flex-direction: column;
gap: 18px;
}
.login-card__links {
display: flex;
justify-content: flex-end;
}
.login-card__error {
margin-top: 18px;
color: #f97066;
font-weight: 600;
}
.login-card__footer {
margin-top: 28px;
display: flex;
flex-direction: column;
gap: 12px;
color: #475467;
font-size: 14px;
}
body.body--dark .login-page {
background: radial-gradient(
circle at top left,
rgba(83, 56, 158, 0.5),
transparent 55%
),
#0b1120;
}
body.body--dark .login-card {
background: rgba(16, 24, 40, 0.94);
box-shadow: 0 22px 44px rgba(12, 18, 32, 0.65);
}
body.body--dark .login-card__header h1 {
color: rgba(255, 255, 255, 0.92);
}
body.body--dark .login-card__header p,
body.body--dark .login-card__footer {
color: rgba(255, 255, 255, 0.7);
}
@media (max-width: 1024px) {
.login-page__container {
grid-template-columns: minmax(0, 1fr);
}
.login-card {
max-width: 460px;
margin: 0 auto;
}
}
@media (max-width: 640px) {
.login-page {
background: #f5f6fa;
}
.login-page__container {
width: 100%;
padding: 24px 16px 64px;
}
.login-page__hero {
display: none;
}
.login-card {
border-radius: 20px;
padding: 28px 20px;
}
}
</style>