Add username-based login + mandatory first-time admin setup; push-ready

- User id/login = username (was email). Email is a separate settable field.
- Default admin: username admin / password 1234, must_setup=True.
- Login forces /setup on first login: set email + change password, then clears must_setup.
- New /api/auth/setup endpoint; JWT sub = username; admin routes use username.
- Frontend: Login uses username, router guard forces /setup, new Setup.vue (email + new
  password + confirm), i18n EN/TH.
- Tests: test_setup.py added; all suites adapted (m0/m1/routes/security/setup/e2e) PASS.
This commit is contained in:
Macky
2026-08-07 17:56:59 +07:00
parent eadf4f727a
commit e8a95bf561
16 changed files with 349 additions and 90 deletions

View File

@@ -33,11 +33,12 @@ async function request(method, url, body, isForm = false) {
}
export const api = {
login: (email, password) => request('POST', '/api/auth/login', { email, password }),
login: (username, password) => request('POST', '/api/auth/login', { username, password }),
me: () => request('GET', '/api/auth/me'),
setup: (b) => request('POST', '/api/auth/setup', b),
adminCreateUser: (b) => request('POST', '/api/admin/users', b),
adminListUsers: () => request('GET', '/api/admin/users'),
adminUpdateUser: (email, b) => request('PUT', `/api/admin/users/${email}`, b),
adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b),
createGroup: (formData) => request('POST', '/api/groups', formData, true),
listGroups: () => request('GET', '/api/groups'),
getGroup: (id) => request('GET', `/api/groups/${id}`),

View File

@@ -6,8 +6,16 @@ const messages = {
app: 'Sales Trainer',
login: 'Login',
logout: 'Logout',
username: 'Username',
email: 'Email',
password: 'Password',
newPassword: 'New password',
confirmPassword: 'Confirm password',
save: 'Save',
passwordTooShort: 'Password must be at least 4 characters',
passwordMismatch: 'Passwords do not match',
setupTitle: 'Set up your account',
setupSubtitle: 'First login for ',
loginError: 'Invalid credentials',
dashboard: 'Dashboard',
groups: 'Persona Groups',
@@ -57,9 +65,17 @@ const messages = {
app: 'ตัวฝึกขาย',
login: 'เข้าสู่ระบบ',
logout: 'ออกจากระบบ',
username: 'ชื่อผู้ใช้',
email: 'อีเมล',
password: 'รหัสผ่าน',
loginError: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
newPassword: 'รหัสผ่านใหม่',
confirmPassword: 'ยืนยันรหัสผ่าน',
save: 'บันทึก',
passwordTooShort: 'รหัสผ่านต้องอย่างน้อย 4 ตัวอักษร',
passwordMismatch: 'รหัสผ่านไม่ตรงกัน',
setupTitle: 'ตั้งค่าบัญชีของคุณ',
setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ',
loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
dashboard: 'หน้าหลัก',
groups: 'กลุ่มลูกค้า (Persona)',
myTraining: 'การฝึกของฉัน',

View File

@@ -3,6 +3,7 @@ import { auth } from '../store/auth'
const routes = [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/setup', component: () => import('../views/Setup.vue') },
{ path: '/', component: () => import('../views/Dashboard.vue') },
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') },
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
@@ -28,6 +29,10 @@ router.beforeEach(async (to) => {
if (!auth.user) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// Force the mandatory first-time setup (set email + change password) before use.
if (auth.mustSetup && to.path !== '/setup') {
return { path: '/setup' }
}
if (to.meta.admin && !auth.isAdmin) {
return { path: '/' }
}

View File

@@ -5,6 +5,7 @@ import { getToken, setToken, api } from '../api'
export const auth = reactive({
user: null,
token: getToken(),
mustSetup: false,
get role() {
return this.user ? this.user.role : null
},
@@ -16,23 +17,33 @@ export const auth = reactive({
try {
const data = await api.me()
this.user = data.user
this.mustSetup = !!data.user?.must_setup
return this.user
} catch (e) {
this.user = null
this.mustSetup = false
setToken(null)
return null
}
},
async login(email, password) {
const data = await api.login(email, password)
async login(username, password) {
const data = await api.login(username, password)
this.token = data.token
setToken(data.token)
this.user = data.user
this.mustSetup = !!data.must_setup
return data.user
},
async finishSetup(email, password) {
const data = await api.setup({ username: this.user.username || this.user.id, email, password })
this.user = data.user
this.mustSetup = false
return data.user
},
logout() {
this.user = null
this.token = null
this.mustSetup = false
setToken(null)
},
})

View File

@@ -3,8 +3,8 @@
<div class="card login-card">
<h1>🎯 {{ i18n.t('app') }}</h1>
<p class="muted" style="margin-top:-8px">Sales training simulator</p>
<label>{{ i18n.t('email') }}</label>
<input v-model="email" type="email" autocomplete="username" @keyup.enter="submit" />
<label>{{ i18n.t('username') }}</label>
<input v-model="username" type="text" autocomplete="username" @keyup.enter="submit" />
<label>{{ i18n.t('password') }}</label>
<div class="pw-wrap">
<input v-model="password" :type="showPw ? 'text' : 'password'" autocomplete="current-password" @keyup.enter="submit" />
@@ -13,7 +13,7 @@
</button>
</div>
<div class="error" role="alert" v-if="error">{{ error }}</div>
<button class="primary" style="width:100%;margin-top:16px" :disabled="loading || !email || !password" @click="submit">
<button class="primary" style="width:100%;margin-top:16px" :disabled="loading || !username || !password" @click="submit">
<span v-if="loading" class="spinner"></span>
<span v-else>{{ i18n.t('login') }}</span>
</button>
@@ -29,7 +29,7 @@ import { i18n } from '../i18n'
const route = useRoute()
const router = useRouter()
const email = ref('')
const username = ref('')
const password = ref('')
const showPw = ref(false)
const error = ref('')
@@ -39,8 +39,13 @@ async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(email.value, password.value)
router.push(route.query.redirect || '/')
await auth.login(username.value.trim(), password.value)
// First-time admin setup is mandatory before using the app.
if (auth.mustSetup) {
router.push({ path: '/setup' })
} else {
router.push(route.query.redirect || '/')
}
} catch (e) {
error.value = i18n.t('loginError')
} finally {

View File

@@ -0,0 +1,65 @@
<template>
<div class="setup-wrap">
<div class="card setup-card">
<h1>🔐 {{ i18n.t('setupTitle') }}</h1>
<p class="muted">{{ i18n.t('setupSubtitle') }} <strong>{{ auth.user?.name || auth.user?.username }}</strong></p>
<label>{{ i18n.t('email') }}</label>
<input v-model="email" type="email" autocomplete="email" @keyup.enter="submit" />
<label>{{ i18n.t('newPassword') }}</label>
<input v-model="password" type="password" autocomplete="new-password" @keyup.enter="submit" />
<label>{{ i18n.t('confirmPassword') }}</label>
<input v-model="confirm" type="password" autocomplete="new-password" @keyup.enter="submit" />
<div class="error" role="alert" v-if="error">{{ error }}</div>
<button class="primary" style="width:100%;margin-top:16px" :disabled="busy || !email || !password || password !== confirm" @click="submit">
<span v-if="busy" class="spinner"></span>
<span v-else>{{ i18n.t('save') }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { auth } from '../store/auth'
import { i18n } from '../i18n'
const router = useRouter()
const email = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
error.value = ''
if (password.value.length < 4) {
error.value = i18n.t('passwordTooShort')
return
}
if (password.value !== confirm.value) {
error.value = i18n.t('passwordMismatch')
return
}
busy.value = true
try {
await auth.finishSetup(email.value.trim(), password.value)
router.push('/')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
</script>
<style scoped>
.setup-wrap { display: flex; justify-content: center; padding-top: 8vh; }
.setup-card { width: 380px; }
h1 { margin-top: 0; }
</style>