feat: multi-usuário com autenticação JWT
- Tabela `profiles` + coluna `profile_id` em todas as entidades (categories, transactions, recurring_expenses, accounts, player_profile, xp_events, player_quests, player_achievements, player_cosmetics) - Dados existentes migrados para profile_id = 1 (Manoel) - CLI `./api create-user --name <n> --password <p>` cria perfil com seed de categorias e player_profile; faz upsert de senha se já existir - Auth substituída: cookie+APP_PASSWORD → JWT Bearer 24h (HS256) - Middleware RequireAuth injeta profile_id no context de todas as rotas - Todos os repositórios filtram por profile_id do context - Endpoints: POST /api/auth/login, GET /api/auth/me, POST /api/auth/change-password, POST /api/logout - Frontend: auth store usa localStorage (fc_token/fc_profile), api.ts envia Authorization header, LoginView usa campo name - SettingsView reescrita com troca de senha e logout - docker-compose.yml: remove APP_USERNAME/APP_PASSWORD, adiciona JWT_SECRET Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -50,7 +50,8 @@ const navItems = [
|
||||
{ to: '/contas', label: 'CONTAS' },
|
||||
{ to: '/personagem', label: 'PERS.' },
|
||||
{ to: '/recorrencias', label: 'REC.' },
|
||||
{ to: '/categorias', label: 'CFG' },
|
||||
{ to: '/categorias', label: 'CAT.' },
|
||||
{ to: '/configuracoes', label: 'CFG' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/configuracoes',
|
||||
redirect: '/categorias',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,13 +2,24 @@ import router from '@/router'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('fc_token')
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body) headers['Content-Type'] = 'application/json'
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('fc_token')
|
||||
localStorage.removeItem('fc_profile')
|
||||
router.push({ name: 'login' })
|
||||
throw new Error('Sessão expirada')
|
||||
}
|
||||
|
||||
+68
-18
@@ -1,41 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const TOKEN_KEY = 'fc_token'
|
||||
const PROFILE_KEY = 'fc_profile'
|
||||
|
||||
interface Profile {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const checked = ref(false)
|
||||
const authenticated = ref(false)
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const profile = ref<Profile | null>(
|
||||
(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_KEY)
|
||||
return raw ? (JSON.parse(raw) as Profile) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
const isAuthenticated = computed(() => token.value !== null && profile.value !== null)
|
||||
|
||||
async function check(): Promise<boolean> {
|
||||
if (checked.value) return authenticated.value
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
authenticated.value = res.status === 204
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
profile.value = data
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
authenticated.value = false
|
||||
// network error — keep cached state
|
||||
return isAuthenticated.value
|
||||
}
|
||||
checked.value = true
|
||||
return authenticated.value
|
||||
_clear()
|
||||
return false
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<void> {
|
||||
const res = await fetch('/api/login', {
|
||||
async function login(name: string, password: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ name, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error ?? 'Erro ao fazer login')
|
||||
}
|
||||
authenticated.value = true
|
||||
checked.value = true
|
||||
const data = await res.json()
|
||||
token.value = data.token
|
||||
profile.value = data.profile
|
||||
localStorage.setItem(TOKEN_KEY, data.token)
|
||||
localStorage.setItem(PROFILE_KEY, JSON.stringify(data.profile))
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
authenticated.value = false
|
||||
checked.value = false
|
||||
await fetch('/api/logout', { method: 'POST' }).catch(() => {})
|
||||
_clear()
|
||||
}
|
||||
|
||||
return { checked, authenticated, check, login, logout }
|
||||
async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token.value}`,
|
||||
},
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error ?? 'Erro ao trocar senha')
|
||||
}
|
||||
}
|
||||
|
||||
function _clear() {
|
||||
token.value = null
|
||||
profile.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(PROFILE_KEY)
|
||||
}
|
||||
|
||||
return { token, profile, isAuthenticated, check, login, logout, changePassword }
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -15,7 +15,7 @@ async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
await auth.login(name.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
error.value = e.message ?? 'Erro desconhecido'
|
||||
@@ -37,7 +37,7 @@ async function submit() {
|
||||
<div class="field">
|
||||
<label class="fc-pixel field-label">USUÁRIO</label>
|
||||
<input
|
||||
v-model="username"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="login-input"
|
||||
autocomplete="username"
|
||||
|
||||
@@ -1,138 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRecurringStore } from '@/stores/recurring'
|
||||
import { useCategoriesStore } from '@/stores/categories'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
|
||||
const store = useRecurringStore()
|
||||
const catStore = useCategoriesStore()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll()
|
||||
catStore.fetchAll()
|
||||
})
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const pwError = ref<string | null>(null)
|
||||
const pwSuccess = ref(false)
|
||||
const pwLoading = ref(false)
|
||||
|
||||
const blank = () => ({ name: '', expected_amount: 0, day_of_month: 1, category_id: null as number | null })
|
||||
const form = ref(blank())
|
||||
const editId = ref<number | null>(null)
|
||||
const amountRaw = ref('')
|
||||
const formError = ref<string | null>(null)
|
||||
|
||||
function parseAmount(s: string) {
|
||||
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
|
||||
}
|
||||
|
||||
function startEdit(id: number) {
|
||||
const item = store.items.find((x) => x.id === id)
|
||||
if (!item) return
|
||||
editId.value = id
|
||||
form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id }
|
||||
amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editId.value = null
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
formError.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError.value = null
|
||||
form.value.expected_amount = parseAmount(amountRaw.value)
|
||||
async function changePassword() {
|
||||
pwError.value = null
|
||||
pwSuccess.value = false
|
||||
if (newPassword.value.length < 8) {
|
||||
pwError.value = 'Nova senha deve ter pelo menos 8 caracteres'
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
pwError.value = 'Senhas não conferem'
|
||||
return
|
||||
}
|
||||
pwLoading.value = true
|
||||
try {
|
||||
if (editId.value !== null) {
|
||||
await store.update(editId.value, form.value)
|
||||
cancelEdit()
|
||||
} else {
|
||||
await store.create(form.value)
|
||||
form.value = blank()
|
||||
amountRaw.value = ''
|
||||
}
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
pwSuccess.value = true
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
} catch (e: any) {
|
||||
formError.value = e.message
|
||||
pwError.value = e.message ?? 'Erro ao trocar senha'
|
||||
} finally {
|
||||
pwLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number, name: string) {
|
||||
if (!confirm(`Excluir recorrência "${name}"?`)) return
|
||||
await store.remove(id)
|
||||
}
|
||||
|
||||
function catName(id: number | null) {
|
||||
if (!id) return '—'
|
||||
return catStore.categories.find((c) => c.id === id)?.name ?? '—'
|
||||
}
|
||||
|
||||
function fmt(v: number) {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-view">
|
||||
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
|
||||
<span class="fc-pixel fc-view__title">:: CONFIGURAÇÕES</span>
|
||||
|
||||
<!-- Form -->
|
||||
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
|
||||
<form class="fc-rec-form" @submit.prevent="submit">
|
||||
<div class="fc-rec-form__row">
|
||||
<input v-model="form.name" placeholder="Nome (ex: Netflix)" required class="fc-input fc-rec-form__name" />
|
||||
<input v-model="amountRaw" placeholder="55,90" required class="fc-input fc-rec-form__amount" />
|
||||
<div class="fc-label fc-rec-form__day-wrap">
|
||||
Dia do mês
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="form.day_of_month"
|
||||
min="1"
|
||||
max="31"
|
||||
class="fc-input fc-rec-form__day"
|
||||
/>
|
||||
</div>
|
||||
<select v-model="form.category_id" class="fc-select fc-rec-form__cat">
|
||||
<option :value="null">Sem categoria</option>
|
||||
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="formError" class="fc-rec-form__error fc-mono">{{ formError }}</p>
|
||||
<div class="fc-rec-form__actions">
|
||||
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
|
||||
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
|
||||
</div>
|
||||
</form>
|
||||
<NeonPanel title="PERFIL">
|
||||
<div class="fc-settings-profile">
|
||||
<div class="fc-label fc-pixel">USUÁRIO</div>
|
||||
<div class="fc-settings-profile__name fc-mono">{{ auth.profile?.name ?? '—' }}</div>
|
||||
</div>
|
||||
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- List -->
|
||||
<NeonPanel title="RECORRÊNCIAS">
|
||||
<p v-if="store.loading" class="fc-rec-loading fc-mono">carregando...</p>
|
||||
<ul v-else class="fc-rec-list">
|
||||
<li
|
||||
v-for="item in store.items"
|
||||
:key="item.id"
|
||||
class="fc-rec-item"
|
||||
:class="{ 'fc-rec-item--editing': editId === item.id }"
|
||||
>
|
||||
<div class="fc-rec-item__info">
|
||||
<span class="fc-body fc-rec-item__name">{{ item.name }}</span>
|
||||
<span class="fc-mono fc-rec-item__meta">
|
||||
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-rec-item__actions">
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="store.items.length === 0" class="fc-rec-empty fc-mono">— nenhuma recorrência cadastrada —</li>
|
||||
</ul>
|
||||
<NeonPanel title="TROCAR SENHA">
|
||||
<form class="fc-settings-pw" @submit.prevent="changePassword">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
placeholder="Senha atual"
|
||||
class="fc-input"
|
||||
autocomplete="current-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="Nova senha (mín. 8 chars)"
|
||||
class="fc-input"
|
||||
autocomplete="new-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirmar nova senha"
|
||||
class="fc-input"
|
||||
autocomplete="new-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<p v-if="pwError" class="fc-settings-pw__msg fc-settings-pw__msg--err fc-mono">{{ pwError }}</p>
|
||||
<p v-if="pwSuccess" class="fc-settings-pw__msg fc-settings-pw__msg--ok fc-mono">Senha alterada!</p>
|
||||
<button type="submit" class="fc-btn fc-btn--primary" :disabled="pwLoading">
|
||||
{{ pwLoading ? 'AGUARDE...' : 'SALVAR SENHA' }}
|
||||
</button>
|
||||
</form>
|
||||
</NeonPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fc-view {
|
||||
max-width: 640px;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: var(--fc-space-4);
|
||||
display: flex;
|
||||
@@ -140,78 +106,32 @@ function fmt(v: number) {
|
||||
gap: var(--fc-space-4);
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.fc-view__title {
|
||||
font-size: 11px;
|
||||
color: var(--fc-accent-2);
|
||||
}
|
||||
|
||||
.fc-rec-form__row {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fc-rec-form__name { flex: 1; min-width: 150px; }
|
||||
.fc-rec-form__amount { width: 110px; }
|
||||
.fc-rec-form__day-wrap { width: 90px; flex-shrink: 0; }
|
||||
.fc-rec-form__day { width: 100%; }
|
||||
.fc-rec-form__cat { width: 160px; }
|
||||
|
||||
.fc-rec-form__error {
|
||||
color: var(--fc-red);
|
||||
font-size: 11px;
|
||||
margin-top: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.fc-rec-form__actions {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
margin-top: var(--fc-space-3);
|
||||
}
|
||||
|
||||
/* List */
|
||||
.fc-rec-loading, .fc-rec-empty {
|
||||
font-size: 11px;
|
||||
color: var(--fc-text-dim);
|
||||
text-align: center;
|
||||
padding: var(--fc-space-4) 0;
|
||||
}
|
||||
|
||||
.fc-rec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.fc-settings-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-2);
|
||||
gap: 6px;
|
||||
margin-bottom: var(--fc-space-4);
|
||||
}
|
||||
|
||||
.fc-rec-item {
|
||||
.fc-settings-profile__name {
|
||||
font-size: 18px;
|
||||
color: var(--fc-text);
|
||||
}
|
||||
.fc-settings-logout {
|
||||
width: 100%;
|
||||
}
|
||||
.fc-settings-pw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-3);
|
||||
padding: 12px var(--fc-space-3);
|
||||
border: 1px solid var(--fc-panel-edge);
|
||||
border-radius: var(--fc-radius);
|
||||
flex-wrap: wrap;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.fc-rec-item--editing {
|
||||
border-color: var(--fc-accent-3);
|
||||
box-shadow: 0 0 8px rgba(168,85,247,.3);
|
||||
.fc-settings-pw__msg {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fc-rec-item__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fc-rec-item__name { font-size: 14px; font-weight: 500; }
|
||||
.fc-rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||
.fc-rec-item__actions { display: flex; gap: var(--fc-space-1); }
|
||||
.fc-settings-pw__msg--err { color: var(--fc-red); }
|
||||
.fc-settings-pw__msg--ok { color: var(--fc-green); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user