Cleaned up login/logout flows.

This commit is contained in:
Leo Vasanko 2025-09-02 19:08:16 -06:00
parent 10e55f63b5
commit b324276173
8 changed files with 46 additions and 18 deletions

View File

@ -22,8 +22,12 @@ import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore() const store = useAuthStore()
onMounted(async () => { onMounted(async () => {
// Detect restricted mode: any path not starting with /auth/ // Detect restricted mode:
if (!location.pathname.startsWith('/auth/')) { // We only allow full functionality on the exact /auth/ (or /auth) path.
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
// so the app will only show login or permission denied views.
const path = location.pathname
if (!(path === '/auth/' || path === '/auth')) {
store.setRestrictedMode(true) store.setRestrictedMode(true)
} }
// Load branding / settings first (non-blocking for auth flow) // Load branding / settings first (non-blocking for auth flow)

View File

@ -27,9 +27,9 @@ const handleLogin = async () => {
await authStore.authenticate() await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000) authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) { if (authStore.restrictedMode) {
// In restricted mode after successful auth show permission denied (no profile outside /auth/) // Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied)
authStore.currentView = 'permission-denied' location.reload()
} else if (location.pathname.startsWith('/auth/')) { } else if (location.pathname === '/auth/') {
authStore.currentView = 'profile' authStore.currentView = 'profile'
} else { } else {
location.reload() location.reload()

View File

@ -32,7 +32,6 @@ function back() {
} }
async function logout() { async function logout() {
await authStore.logout() await authStore.logout()
authStore.currentView = 'login'
} }
</script> </script>
<style scoped> <style scoped>

View File

@ -144,7 +144,6 @@ const deleteCredential = async (credentialId) => {
const logout = async () => { const logout = async () => {
await authStore.logout() await authStore.logout()
authStore.currentView = 'login'
} }
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))

View File

@ -8,7 +8,7 @@ export const useAuthStore = defineStore('auth', {
settings: null, // Server provided settings (/auth/settings) settings: null, // Server provided settings (/auth/settings)
isLoading: false, isLoading: false,
resetToken: null, // transient reset token resetToken: null, // transient reset token
restrictedMode: false, // If true, app loaded outside /auth/ and should restrict to login or permission denied restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
// UI State // UI State
currentView: 'login', currentView: 'login',
@ -129,12 +129,13 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
try { try {
await fetch('/auth/api/logout', {method: 'POST'}) await fetch('/auth/api/logout', {method: 'POST'})
this.userInfo = null
if (this.restrictedMode) location.reload()
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error('Logout error:', error)
this.showMessage(error.message, 'error')
} }
this.userInfo = null
}, },
} }
}) })

View File

@ -1,8 +1,8 @@
import logging import logging
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException from fastapi import Body, Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from ..authsession import expires from ..authsession import expires
from ..globals import db from ..globals import db
@ -24,8 +24,14 @@ async def general_exception_handler(_request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": "Internal server error"}) return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("")
def adminapp_slashmissing(request: Request):
print("HERE")
return RedirectResponse(url=request.url_for("adminapp"))
@app.get("/") @app.get("/")
async def admin_frontend(auth=Cookie(None)): async def adminapp(auth=Cookie(None)):
try: try:
await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
return FileResponse(frontend.file("admin/index.html")) return FileResponse(frontend.file("admin/index.html"))

View File

@ -1,7 +1,11 @@
import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import permutil from ..util import permutil
logger = logging.getLogger(__name__)
async def verify(auth: str | None, perm: list[str], match=permutil.has_all): async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
"""Validate session token and optional list of required permissions. """Validate session token and optional list of required permissions.
@ -20,6 +24,16 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
raise HTTPException(status_code=401, detail="Session not found") raise HTTPException(status_code=401, detail="Session not found")
if not match(ctx, perm): if not match(ctx, perm):
# Determine which permissions are missing for clearer diagnostics
missing = sorted(set(perm) - set(ctx.role.permissions))
logger.warning(
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
getattr(ctx.user, "uuid", "?"),
getattr(ctx.role, "display_name", "?"),
missing,
perm,
ctx.role.permissions,
)
raise HTTPException(status_code=403, detail="Permission required") raise HTTPException(status_code=403, detail="Permission required")
return ctx return ctx

View File

@ -46,10 +46,12 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.mount("/auth/admin", admin.app) app.mount("/auth/admin/", admin.app)
app.mount("/auth/api", api.app) app.mount("/auth/api/", api.app)
app.mount("/auth/ws", ws.app) app.mount("/auth/ws/", ws.app)
app.mount("/auth/assets", StaticFiles(directory=frontend.file("assets")), name="assets") app.mount(
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets"
)
@app.get("/auth/") @app.get("/auth/")
@ -61,6 +63,9 @@ async def frontapp():
@app.get("/auth/{reset}") @app.get("/auth/{reset}")
async def reset_link(request: Request, reset: str): async def reset_link(request: Request, reset: str):
"""Pretty URL for reset links.""" """Pretty URL for reset links."""
if reset == "admin":
# Admin app missing trailing slash lands here, be friendly to user
return RedirectResponse(request.url_for("adminapp"), status_code=303)
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
url = request.url_for("frontapp").include_query_params(reset=reset) url = request.url_for("frontapp").include_query_params(reset=reset)