Compare commits
3 Commits
89097a390e
...
v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 861b3dc9d4 | |||
| 8080a59de1 | |||
| a4e23aad11 |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
1
.gitignore
vendored
@@ -5,3 +5,4 @@ resources/
|
|||||||
frontend/vue-app/playwright-report/
|
frontend/vue-app/playwright-report/
|
||||||
frontend/vue-app/test-results/
|
frontend/vue-app/test-results/
|
||||||
backend/test-results/
|
backend/test-results/
|
||||||
|
.vscode/keybindings.json
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ def _create_refresh_token(user_id: str, token_family: str | None = None) -> tupl
|
|||||||
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
|
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
|
||||||
"""Set both access and refresh token cookies on a response."""
|
"""Set both access and refresh token cookies on a response."""
|
||||||
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
||||||
resp.set_cookie('access_token', access_token, httponly=True, secure=True, samesite='Strict')
|
resp.set_cookie(
|
||||||
|
'access_token', access_token,
|
||||||
|
httponly=True, secure=True, samesite='Lax',
|
||||||
|
max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60,
|
||||||
|
)
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
'refresh_token', raw_refresh_token,
|
'refresh_token', raw_refresh_token,
|
||||||
httponly=True, secure=True, samesite='Strict',
|
httponly=True, secure=True, samesite='Strict',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_VERSION = "1.0.6" # update manually when releasing features
|
BASE_VERSION = "1.0.8" # update manually when releasing features
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
{
|
|
||||||
"_default": {
|
|
||||||
"1": {
|
|
||||||
"id": "57c21328-637e-4df3-be5b-7f619cbf4076",
|
|
||||||
"created_at": 1771343995.1881204,
|
|
||||||
"updated_at": 1771343995.188121,
|
|
||||||
"name": "Take out trash",
|
|
||||||
"points": 20,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "trash-can",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"id": "70316500-e4ce-4399-8e4b-86a4046fafcb",
|
|
||||||
"created_at": 1771343995.1881304,
|
|
||||||
"updated_at": 1771343995.1881304,
|
|
||||||
"name": "Make your bed",
|
|
||||||
"points": 25,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "make-the-bed",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"id": "71afb2e5-18de-4f99-9e1e-2f4e391e6c2c",
|
|
||||||
"created_at": 1771343995.1881359,
|
|
||||||
"updated_at": 1771343995.1881359,
|
|
||||||
"name": "Sweep and clean kitchen",
|
|
||||||
"points": 15,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "vacuum",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"4": {
|
|
||||||
"id": "e0aae53d-d4b6-4203-b910-004917db6003",
|
|
||||||
"created_at": 1771343995.1881409,
|
|
||||||
"updated_at": 1771343995.188141,
|
|
||||||
"name": "Do homework early",
|
|
||||||
"points": 30,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "homework",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"5": {
|
|
||||||
"id": "0ba544f6-2d61-4009-af8f-bcb4e94b7a11",
|
|
||||||
"created_at": 1771343995.188146,
|
|
||||||
"updated_at": 1771343995.188146,
|
|
||||||
"name": "Be good for the day",
|
|
||||||
"points": 15,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "good",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"id": "8b5750d4-5a58-40cb-a31b-667569069d34",
|
|
||||||
"created_at": 1771343995.1881511,
|
|
||||||
"updated_at": 1771343995.1881511,
|
|
||||||
"name": "Clean your mess",
|
|
||||||
"points": 20,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "broom",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"id": "aec5fb49-06d0-43c4-aa09-9583064b7275",
|
|
||||||
"created_at": 1771343995.1881557,
|
|
||||||
"updated_at": 1771343995.1881557,
|
|
||||||
"name": "Fighting",
|
|
||||||
"points": 10,
|
|
||||||
"is_good": false,
|
|
||||||
"image_id": "fighting",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"id": "0221ab72-c6c0-429f-a5f1-bc3d843fce9e",
|
|
||||||
"created_at": 1771343995.1881602,
|
|
||||||
"updated_at": 1771343995.1881602,
|
|
||||||
"name": "Yelling at parents",
|
|
||||||
"points": 10,
|
|
||||||
"is_good": false,
|
|
||||||
"image_id": "yelling",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"9": {
|
|
||||||
"id": "672bfc74-4b85-4e8e-a2d0-74f14ab966cc",
|
|
||||||
"created_at": 1771343995.1881647,
|
|
||||||
"updated_at": 1771343995.1881647,
|
|
||||||
"name": "Lying",
|
|
||||||
"points": 10,
|
|
||||||
"is_good": false,
|
|
||||||
"image_id": "lying",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"10": {
|
|
||||||
"id": "d8cc254f-922b-4dc2-ac4c-32fc3bbda584",
|
|
||||||
"created_at": 1771343995.1881692,
|
|
||||||
"updated_at": 1771343995.1881695,
|
|
||||||
"name": "Not doing what told",
|
|
||||||
"points": 5,
|
|
||||||
"is_good": false,
|
|
||||||
"image_id": "ignore",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"id": "8be18d9a-48e6-402b-a0ba-630a2d50e325",
|
|
||||||
"created_at": 1771343995.188174,
|
|
||||||
"updated_at": 1771343995.188174,
|
|
||||||
"name": "Not flushing toilet",
|
|
||||||
"points": 5,
|
|
||||||
"is_good": false,
|
|
||||||
"image_id": "toilet",
|
|
||||||
"user_id": null
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"id": "b3b44115-529b-4eb3-9f8b-686dd24547a1",
|
|
||||||
"created_at": 1771345063.4665146,
|
|
||||||
"updated_at": 1771345063.4665148,
|
|
||||||
"name": "Take out trash",
|
|
||||||
"points": 21,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "trash-can",
|
|
||||||
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"id": "c74fc8c7-5af1-4d40-afbb-6da2647ca18b",
|
|
||||||
"created_at": 1771345069.1633172,
|
|
||||||
"updated_at": 1771345069.1633174,
|
|
||||||
"name": "aaa",
|
|
||||||
"points": 1,
|
|
||||||
"is_good": true,
|
|
||||||
"image_id": "computer-game",
|
|
||||||
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
|
|
||||||
},
|
|
||||||
"14": {
|
|
||||||
"id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80",
|
|
||||||
"created_at": 1772251855.4823341,
|
|
||||||
"updated_at": 1772251855.4823341,
|
|
||||||
"name": "Be Cool \ud83d\ude0e",
|
|
||||||
"points": 5,
|
|
||||||
"type": "kindness",
|
|
||||||
"image_id": "58d4adb9-3cee-4d7c-8e90-d81173716ce5",
|
|
||||||
"user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "oQxjeMmAW3v4aoI_hBdjpMXThVDzKZTG_6bQGPJBokc",
|
"value": "pjakYk7dm_bGf-VXaBpRXFNNq08FH0csnh3nBhCqWGw",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1782271329.747941,
|
"expires": 1783633314.818346,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIyYjQ1MmRiNi1lYTRmLTQyMDItODIzYi0zNjllN2E3YTQ5MjYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0OTYyMjl9.docokOZYHgPtNNqirbfLSRABQHHsmTj-9_txuR8NHOM",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI3NWQ3NGU4OC1jMjY0LTQ3MGMtOThiNC02ZTQ0YTBkYzkyOTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzU4NTgyMTR9.lXWnj-O3MszrJ_GIwp6ViN0W5-5XEpSDVlAQIQSll-Q",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": 1775858214.818293,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Lax"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"origins": [
|
"origins": [
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1774495329388}"
|
"value": "{\"type\":\"logout\",\"at\":1775857314645}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1774668130069}"
|
"value": "{\"expiresAt\":1776030114975}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "PcQvgm3e7SXL3CHGNXYGXhRrU7erXomAQDbOzVAUrCQ",
|
"value": "sTFHvjZ5hJxJrlNHoxdWkNJ6vxdDzrZwTRFz55JxWKs",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1782271328.966396,
|
"expires": 1783633314.820088,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiOWM4ODk0MWUtMjhiMy00YzFmLWJhNDctMDNkMTU0MTc2ODA5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDk2MjI4fQ.xxzCNQEX6y07hFJzEdumbYbhm_52VWM90tHsTPDfSKs",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiY2U4MmI2MzMtMDUwMy00MmMxLWFlNjMtZmEzNzcxMmMxODZiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc1ODU4MjE0fQ.meuVUYIk9NFuo66KDhLPZHoXgHRLWmRDGyR23doTLIw",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": 1775858214.820046,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Lax"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"origins": [
|
"origins": [
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1774495328432}"
|
"value": "{\"type\":\"logout\",\"at\":1775857314657}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1774668129476}"
|
"value": "{\"expiresAt\":1776030114992}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "K9dRbu3607bZL23pmoMCD8qGM697tnpfY-dYVJ7qXPU",
|
"value": "nbOCYWuMXIGNyjN2uc6uohUFqkl-Bv1d01YjAOirGi8",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1782271324.066253,
|
"expires": 1783633312.982067,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiMGFhNDczMC05ZDI4LTQ3NzQtODE3NC1kZTZmZWJkMjZjMmIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0OTYyMjR9.3RoSBnOU30YFn61lbfKo_ZMF2LQcsKb7qO31CK4AOl8",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJlNjM1MjRiOS03NmIzLTRiMmQtODc1Ni05ZmNkNTk0MDRhYTkiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzU4NTgyMTJ9.ztnqGJFbFDSze0GvK-2G37v5HaDdWRK392T7vsUpnhw",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": 1775858212.982022,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Lax"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"origins": [
|
"origins": [
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1774495323817}"
|
"value": "{\"type\":\"logout\",\"at\":1775857312837}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1774668124255}"
|
"value": "{\"expiresAt\":1776030113109}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/vue-app/public/children.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
frontend/vue-app/public/chores.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/vue-app/public/notifications.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/vue-app/public/rewards.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -1,5 +1,10 @@
|
|||||||
import { logoutUser } from '@/stores/auth'
|
import { logoutUser } from '@/stores/auth'
|
||||||
|
|
||||||
|
const hasLocalStorage =
|
||||||
|
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
|
||||||
|
const CROSS_TAB_REFRESH_KEY = 'lastRefreshAt'
|
||||||
|
const CROSS_TAB_REFRESH_MIN_INTERVAL = 8_000 // 8s — prevents concurrent multi-tab refresh
|
||||||
|
|
||||||
let unauthorizedInterceptorInstalled = false
|
let unauthorizedInterceptorInstalled = false
|
||||||
let unauthorizedRedirectHandler: (() => void) | null = null
|
let unauthorizedRedirectHandler: (() => void) | null = null
|
||||||
let unauthorizedHandlingInProgress = false
|
let unauthorizedHandlingInProgress = false
|
||||||
@@ -15,6 +20,9 @@ export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | n
|
|||||||
export function resetInterceptorStateForTests(): void {
|
export function resetInterceptorStateForTests(): void {
|
||||||
unauthorizedHandlingInProgress = false
|
unauthorizedHandlingInProgress = false
|
||||||
refreshPromise = null
|
refreshPromise = null
|
||||||
|
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
||||||
|
localStorage.removeItem(CROSS_TAB_REFRESH_KEY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnauthorizedResponse(): void {
|
function handleUnauthorizedResponse(): void {
|
||||||
@@ -35,13 +43,27 @@ function handleUnauthorizedResponse(): void {
|
|||||||
* Attempt to refresh the access token by calling the refresh endpoint.
|
* Attempt to refresh the access token by calling the refresh endpoint.
|
||||||
* Returns true if refresh succeeded, false otherwise.
|
* Returns true if refresh succeeded, false otherwise.
|
||||||
* Uses a mutex so concurrent 401s only trigger one refresh call.
|
* Uses a mutex so concurrent 401s only trigger one refresh call.
|
||||||
|
* Also uses a localStorage timestamp to coordinate across tabs, preventing
|
||||||
|
* concurrent refreshes that would trigger false-positive theft detection.
|
||||||
*/
|
*/
|
||||||
async function attemptTokenRefresh(originalFetch: typeof fetch): Promise<boolean> {
|
async function attemptTokenRefresh(originalFetch: typeof fetch): Promise<boolean> {
|
||||||
|
// Cross-tab coordination: if another tab refreshed recently, the new cookie
|
||||||
|
// is already set. Skip the refresh call and let the original request retry.
|
||||||
|
if (hasLocalStorage && typeof localStorage.getItem === 'function') {
|
||||||
|
const lastRefresh = localStorage.getItem(CROSS_TAB_REFRESH_KEY)
|
||||||
|
if (lastRefresh && Date.now() - Number(lastRefresh) < CROSS_TAB_REFRESH_MIN_INTERVAL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshPromise) return refreshPromise
|
if (refreshPromise) return refreshPromise
|
||||||
|
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await originalFetch('/api/auth/refresh', { method: 'POST' })
|
const res = await originalFetch('/api/auth/refresh', { method: 'POST' })
|
||||||
|
if (res.ok && hasLocalStorage && typeof localStorage.setItem === 'function') {
|
||||||
|
localStorage.setItem(CROSS_TAB_REFRESH_KEY, String(Date.now()))
|
||||||
|
}
|
||||||
return res.ok
|
return res.ok
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useBackendEvents } from '@/common/backendEvents'
|
import { useBackendEvents } from '@/common/backendEvents'
|
||||||
import { currentUserId, logoutUser, suppressForceLogout } from '@/stores/auth'
|
import { currentUserId, logoutParent, logoutUser, suppressForceLogout } from '@/stores/auth'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
const userId = ref(currentUserId.value)
|
const userId = ref(currentUserId.value)
|
||||||
@@ -20,6 +20,7 @@ function handleForceLogout(event: { payload?: { reason?: string } }) {
|
|||||||
suppressForceLogout.value = false
|
suppressForceLogout.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logoutParent()
|
||||||
logoutUser()
|
logoutUser()
|
||||||
if (event?.payload?.reason === 'account_deleted') {
|
if (event?.payload?.reason === 'account_deleted') {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
|||||||
@@ -5,16 +5,20 @@ import BackendEventsListener from '../BackendEventsListener.vue'
|
|||||||
|
|
||||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { mockLogoutUser, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(() => ({
|
const { mockLogoutUser, mockLogoutParent, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
mockLogoutUser: vi.fn(),
|
mockLogoutUser: vi.fn(),
|
||||||
|
mockLogoutParent: vi.fn(),
|
||||||
mockSuppressForceLogout: { value: false },
|
mockSuppressForceLogout: { value: false },
|
||||||
mockCurrentUserId: { value: 'user-1' },
|
mockCurrentUserId: { value: 'user-1' },
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
let capturedForceLogoutHandler: ((event: any) => void) | null = null
|
let capturedForceLogoutHandler: ((event: any) => void) | null = null
|
||||||
|
|
||||||
vi.mock('@/stores/auth', () => ({
|
vi.mock('@/stores/auth', () => ({
|
||||||
currentUserId: mockCurrentUserId,
|
currentUserId: mockCurrentUserId,
|
||||||
|
logoutParent: mockLogoutParent,
|
||||||
logoutUser: mockLogoutUser,
|
logoutUser: mockLogoutUser,
|
||||||
suppressForceLogout: mockSuppressForceLogout,
|
suppressForceLogout: mockSuppressForceLogout,
|
||||||
}))
|
}))
|
||||||
@@ -70,6 +74,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
fireForceLogout('password_reset')
|
fireForceLogout('password_reset')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockLogoutParent).toHaveBeenCalledOnce()
|
||||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||||
})
|
})
|
||||||
@@ -81,6 +86,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
fireForceLogout('account_deleted')
|
fireForceLogout('account_deleted')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockLogoutParent).toHaveBeenCalledOnce()
|
||||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||||
expect(pushSpy).toHaveBeenCalledWith('/')
|
expect(pushSpy).toHaveBeenCalledWith('/')
|
||||||
})
|
})
|
||||||
@@ -92,6 +98,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
fireForceLogout('something_else')
|
fireForceLogout('something_else')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockLogoutParent).toHaveBeenCalledOnce()
|
||||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||||
})
|
})
|
||||||
@@ -103,6 +110,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
fireForceLogout()
|
fireForceLogout()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockLogoutParent).toHaveBeenCalledOnce()
|
||||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||||
})
|
})
|
||||||
@@ -115,6 +123,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
fireForceLogout('password_reset')
|
fireForceLogout('password_reset')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockLogoutParent).not.toHaveBeenCalled()
|
||||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||||
expect(pushSpy).not.toHaveBeenCalled()
|
expect(pushSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -142,6 +151,7 @@ describe('BackendEventsListener – force_logout', () => {
|
|||||||
// Second event: should go through normally
|
// Second event: should go through normally
|
||||||
fireForceLogout('password_reset')
|
fireForceLogout('password_reset')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(mockLogoutParent).toHaveBeenCalledOnce()
|
||||||
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
expect(mockLogoutUser).toHaveBeenCalledOnce()
|
||||||
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ function executeMenuItem(index: number) {
|
|||||||
async function signOut() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
logoutParent()
|
||||||
logoutUser()
|
logoutUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ vi.mock('@/stores/auth', () => ({
|
|||||||
isAuthReady: isAuthReadyMock,
|
isAuthReady: isAuthReadyMock,
|
||||||
isUserLoggedIn: isUserLoggedInMock,
|
isUserLoggedIn: isUserLoggedInMock,
|
||||||
isParentAuthenticated: isParentAuthenticatedMock,
|
isParentAuthenticated: isParentAuthenticatedMock,
|
||||||
logoutParent: vi.fn(),
|
|
||||||
enforceParentExpiry: vi.fn(),
|
enforceParentExpiry: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
isUserLoggedIn,
|
isUserLoggedIn,
|
||||||
isParentAuthenticated,
|
isParentAuthenticated,
|
||||||
isAuthReady,
|
isAuthReady,
|
||||||
logoutParent,
|
|
||||||
enforceParentExpiry,
|
enforceParentExpiry,
|
||||||
} from '../stores/auth'
|
} from '../stores/auth'
|
||||||
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
||||||
@@ -304,8 +303,6 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
if (isParentAuthenticated.value) {
|
if (isParentAuthenticated.value) {
|
||||||
return next('/parent')
|
return next('/parent')
|
||||||
} else {
|
} else {
|
||||||
// Ensure parent auth is fully cleared when redirecting away from /parent
|
|
||||||
logoutParent()
|
|
||||||
return next('/child')
|
return next('/child')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,32 +12,8 @@ export const isParentAuthenticated = ref(false)
|
|||||||
export const isParentPersistent = ref(false)
|
export const isParentPersistent = ref(false)
|
||||||
export const parentAuthExpiresAt = ref<number | null>(null)
|
export const parentAuthExpiresAt = ref<number | null>(null)
|
||||||
|
|
||||||
// Restore persistent parent auth from localStorage on store init
|
|
||||||
if (hasLocalStorage) {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(PARENT_AUTH_KEY)
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored) as { expiresAt: number }
|
|
||||||
if (parsed.expiresAt && Date.now() < parsed.expiresAt) {
|
|
||||||
parentAuthExpiresAt.value = parsed.expiresAt
|
|
||||||
isParentPersistent.value = true
|
|
||||||
isParentAuthenticated.value = true
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isUserLoggedIn = ref(false)
|
|
||||||
export const isAuthReady = ref(false)
|
|
||||||
export const currentUserId = ref('')
|
|
||||||
export const suppressForceLogout = ref(false)
|
|
||||||
let authSyncInitialized = false
|
|
||||||
|
|
||||||
// --- Background expiry watcher ---
|
// --- Background expiry watcher ---
|
||||||
|
// Declared before the init block so startParentExpiryWatcher is safe to call during restore.
|
||||||
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null
|
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function runExpiryCheck() {
|
function runExpiryCheck() {
|
||||||
@@ -61,6 +37,32 @@ export function stopParentExpiryWatcher() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore persistent parent auth from localStorage on store init
|
||||||
|
if (hasLocalStorage) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PARENT_AUTH_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as { expiresAt: number }
|
||||||
|
if (parsed.expiresAt && Date.now() < parsed.expiresAt) {
|
||||||
|
parentAuthExpiresAt.value = parsed.expiresAt
|
||||||
|
isParentPersistent.value = true
|
||||||
|
isParentAuthenticated.value = true
|
||||||
|
startParentExpiryWatcher()
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUserLoggedIn = ref(false)
|
||||||
|
export const isAuthReady = ref(false)
|
||||||
|
export const currentUserId = ref('')
|
||||||
|
export const suppressForceLogout = ref(false)
|
||||||
|
let authSyncInitialized = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly checks whether parent auth has expired and clears it if so.
|
* Explicitly checks whether parent auth has expired and clears it if so.
|
||||||
* Called by the router guard before allowing /parent routes.
|
* Called by the router guard before allowing /parent routes.
|
||||||
@@ -81,6 +83,9 @@ export function authenticateParent(persistent: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function logoutParent() {
|
export function logoutParent() {
|
||||||
|
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
||||||
|
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||||
|
}
|
||||||
applyParentLoggedOutState()
|
applyParentLoggedOutState()
|
||||||
broadcastParentLogoutEvent()
|
broadcastParentLogoutEvent()
|
||||||
}
|
}
|
||||||
@@ -89,9 +94,6 @@ function applyParentLoggedOutState() {
|
|||||||
parentAuthExpiresAt.value = null
|
parentAuthExpiresAt.value = null
|
||||||
isParentPersistent.value = false
|
isParentPersistent.value = false
|
||||||
isParentAuthenticated.value = false
|
isParentAuthenticated.value = false
|
||||||
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
|
||||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
|
||||||
}
|
|
||||||
stopParentExpiryWatcher()
|
stopParentExpiryWatcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +107,11 @@ function broadcastParentLogoutEvent() {
|
|||||||
|
|
||||||
export function loginUser() {
|
export function loginUser() {
|
||||||
isUserLoggedIn.value = true
|
isUserLoggedIn.value = true
|
||||||
// Always start in child mode after login
|
// Always start in child mode after login. Also explicitly remove persistent parent
|
||||||
|
// auth from localStorage so a fresh login always starts in child mode.
|
||||||
|
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
||||||
|
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||||
|
}
|
||||||
applyParentLoggedOutState()
|
applyParentLoggedOutState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +164,12 @@ export async function checkAuth() {
|
|||||||
currentUserId.value = data.id
|
currentUserId.value = data.id
|
||||||
isUserLoggedIn.value = true
|
isUserLoggedIn.value = true
|
||||||
} else {
|
} else {
|
||||||
logoutUser()
|
isUserLoggedIn.value = false
|
||||||
|
currentUserId.value = ''
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logoutUser()
|
isUserLoggedIn.value = false
|
||||||
|
currentUserId.value = ''
|
||||||
}
|
}
|
||||||
isAuthReady.value = true
|
isAuthReady.value = true
|
||||||
}
|
}
|
||||||
|
|||||||