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:
2026-05-27 20:04:44 -03:00
co-authored by Claude Sonnet 4.6
parent 50637bd590
commit acbce4edc0
49 changed files with 2662 additions and 382 deletions
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -48,7 +48,8 @@ const router = createRouter({
},
{
path: '/configuracoes',
redirect: '/categorias',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
],
})
+12 -1
View File
@@ -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
View File
@@ -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 }
})
+3 -3
View File
@@ -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"
+94 -174
View File
@@ -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>