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:
@@ -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}`),
|
||||
|
||||
@@ -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: 'การฝึกของฉัน',
|
||||
|
||||
@@ -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: '/' }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
frontend/src/views/Setup.vue
Normal file
65
frontend/src/views/Setup.vue
Normal 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>
|
||||
Reference in New Issue
Block a user