diff --git a/backend/config/__init__.py b/backend/config/__init__.py
index e4ba1e0cd..9e8ce4b69 100644
--- a/backend/config/__init__.py
+++ b/backend/config/__init__.py
@@ -115,6 +115,7 @@ def str_to_bool(value: str) -> bool:
# FRONTEND
UPLOAD_TIMEOUT = int(os.environ.get("UPLOAD_TIMEOUT", 600))
+KIOSK_MODE = str_to_bool(os.environ.get("KIOSK_MODE", "false"))
# LOGGING
LOGLEVEL: Final = os.environ.get("LOGLEVEL", "INFO").upper()
diff --git a/backend/decorators/auth.py b/backend/decorators/auth.py
index 5c8830151..2a93f7089 100644
--- a/backend/decorators/auth.py
+++ b/backend/decorators/auth.py
@@ -16,8 +16,9 @@
from fastapi.security.oauth2 import OAuth2PasswordBearer
from fastapi.types import DecoratedCallable
from handler.auth.constants import (
- DEFAULT_SCOPES_MAP,
+ EDIT_SCOPES_MAP,
FULL_SCOPES_MAP,
+ READ_SCOPES_MAP,
WRITE_SCOPES_MAP,
Scope,
)
@@ -29,8 +30,9 @@
tokenUrl="/token",
auto_error=False,
scopes={
- **DEFAULT_SCOPES_MAP,
+ **READ_SCOPES_MAP,
**WRITE_SCOPES_MAP,
+ **EDIT_SCOPES_MAP,
**FULL_SCOPES_MAP,
},
)
diff --git a/backend/endpoints/tests/test_oauth.py b/backend/endpoints/tests/test_oauth.py
index 40ae26036..12965acc3 100644
--- a/backend/endpoints/tests/test_oauth.py
+++ b/backend/endpoints/tests/test_oauth.py
@@ -2,7 +2,7 @@
from endpoints.auth import ACCESS_TOKEN_EXPIRE_MINUTES
from fastapi.exceptions import HTTPException
from fastapi.testclient import TestClient
-from handler.auth.constants import WRITE_SCOPES
+from handler.auth.constants import EDIT_SCOPES
from main import app
@@ -96,7 +96,7 @@ def test_auth_via_upass_with_excess_scopes(client, viewer_user):
"grant_type": "password",
"username": "test_viewer",
"password": "test_viewer_password",
- "scopes": WRITE_SCOPES,
+ "scopes": EDIT_SCOPES,
},
)
except HTTPException as e:
diff --git a/backend/handler/auth/constants.py b/backend/handler/auth/constants.py
index 632d60404..7b4c8e9b9 100644
--- a/backend/handler/auth/constants.py
+++ b/backend/handler/auth/constants.py
@@ -26,21 +26,24 @@ class Scope(enum.StrEnum):
TASKS_RUN = "tasks.run"
-DEFAULT_SCOPES_MAP: Final = {
+READ_SCOPES_MAP: Final = {
Scope.ME_READ: "View your profile",
- Scope.ME_WRITE: "Modify your profile",
Scope.ROMS_READ: "View ROMs",
Scope.PLATFORMS_READ: "View platforms",
Scope.ASSETS_READ: "View assets",
- Scope.ASSETS_WRITE: "Modify assets",
Scope.FIRMWARE_READ: "View firmware",
Scope.ROMS_USER_READ: "View user-rom properties",
- Scope.ROMS_USER_WRITE: "Modify user-rom properties",
Scope.COLLECTIONS_READ: "View collections",
- Scope.COLLECTIONS_WRITE: "Modify collections",
}
WRITE_SCOPES_MAP: Final = {
+ Scope.ME_WRITE: "Modify your profile",
+ Scope.ASSETS_WRITE: "Modify assets",
+ Scope.ROMS_USER_WRITE: "Modify user-rom properties",
+ Scope.COLLECTIONS_WRITE: "Modify collections",
+}
+
+EDIT_SCOPES_MAP: Final = {
Scope.ROMS_WRITE: "Modify ROMs",
Scope.PLATFORMS_WRITE: "Modify platforms",
Scope.FIRMWARE_WRITE: "Modify firmware",
@@ -52,6 +55,7 @@ class Scope(enum.StrEnum):
Scope.TASKS_RUN: "Run tasks",
}
-DEFAULT_SCOPES: Final = list(DEFAULT_SCOPES_MAP.keys())
-WRITE_SCOPES: Final = DEFAULT_SCOPES + list(WRITE_SCOPES_MAP.keys())
-FULL_SCOPES: Final = WRITE_SCOPES + list(FULL_SCOPES_MAP.keys())
+READ_SCOPES: Final = list(READ_SCOPES_MAP.keys())
+WRITE_SCOPES: Final = READ_SCOPES + list(WRITE_SCOPES_MAP.keys())
+EDIT_SCOPES: Final = WRITE_SCOPES + list(EDIT_SCOPES_MAP.keys())
+FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys())
diff --git a/backend/handler/auth/hybrid_auth.py b/backend/handler/auth/hybrid_auth.py
index e9a258404..b8e6e6dbc 100644
--- a/backend/handler/auth/hybrid_auth.py
+++ b/backend/handler/auth/hybrid_auth.py
@@ -1,9 +1,12 @@
+from config import KIOSK_MODE
from fastapi.security.http import HTTPBasic
from handler.auth import auth_handler, oauth_handler
from models.user import User
from starlette.authentication import AuthCredentials, AuthenticationBackend
from starlette.requests import HTTPConnection
+from .constants import READ_SCOPES
+
class HybridAuthBackend(AuthenticationBackend):
async def authenticate(
@@ -16,44 +19,47 @@ async def authenticate(
return (AuthCredentials(user.oauth_scopes), user)
# Check if Authorization header exists
- if "Authorization" not in conn.headers:
- return None
+ if "Authorization" in conn.headers:
+ scheme, token = conn.headers["Authorization"].split()
- scheme, token = conn.headers["Authorization"].split()
+ # Check if basic auth header is valid
+ if scheme.lower() == "basic":
+ credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type]
+ if not credentials:
+ return None
- # Check if basic auth header is valid
- if scheme.lower() == "basic":
- credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type]
- if not credentials:
- return None
+ user = auth_handler.authenticate_user(
+ credentials.username, credentials.password
+ )
+ if user is None:
+ return None
- user = auth_handler.authenticate_user(
- credentials.username, credentials.password
- )
- if user is None:
- return None
+ user.set_last_active()
+ return (AuthCredentials(user.oauth_scopes), user)
- user.set_last_active()
- return (AuthCredentials(user.oauth_scopes), user)
+ # Check if bearer auth header is valid
+ if scheme.lower() == "bearer":
+ (
+ user,
+ claims,
+ ) = await oauth_handler.get_current_active_user_from_bearer_token(token)
+ if user is None or claims is None:
+ return None
- # Check if bearer auth header is valid
- if scheme.lower() == "bearer":
- (
- user,
- claims,
- ) = await oauth_handler.get_current_active_user_from_bearer_token(token)
- if user is None or claims is None:
- return None
+ # Only access tokens can request resources
+ if claims.get("type") != "access":
+ return None
- # Only access tokens can request resources
- if claims.get("type") != "access":
- return None
+ # Only grant access to resources with overlapping scopes
+ token_scopes = set(list(claims.get("scopes", "").split(" ")))
+ overlapping_scopes = list(token_scopes & set(user.oauth_scopes))
- # Only grant access to resources with overlapping scopes
- token_scopes = set(list(claims.get("scopes", "").split(" ")))
- overlapping_scopes = list(token_scopes & set(user.oauth_scopes))
+ user.set_last_active()
+ return (AuthCredentials(overlapping_scopes), user)
- user.set_last_active()
- return (AuthCredentials(overlapping_scopes), user)
+ # Check if we're in KIOSK_MODE
+ if KIOSK_MODE:
+ user = User.kiosk_mode_user()
+ return (AuthCredentials(READ_SCOPES), user)
return None
diff --git a/backend/handler/auth/tests/test_auth.py b/backend/handler/auth/tests/test_auth.py
index 05092bdd0..d3924fa3d 100644
--- a/backend/handler/auth/tests/test_auth.py
+++ b/backend/handler/auth/tests/test_auth.py
@@ -3,7 +3,7 @@
import pytest
from fastapi.exceptions import HTTPException
from handler.auth import auth_handler, oauth_handler
-from handler.auth.constants import WRITE_SCOPES
+from handler.auth.constants import EDIT_SCOPES
from handler.auth.hybrid_auth import HybridAuthBackend
from handler.database import db_user_handler
from models.user import User
@@ -88,7 +88,7 @@ def __init__(self):
creds, user = result
assert user.id == editor_user.id
assert creds.scopes == editor_user.oauth_scopes
- assert creds.scopes == WRITE_SCOPES
+ assert creds.scopes == EDIT_SCOPES
async def test_hybrid_auth_backend_empty_session_and_headers(editor_user: User):
@@ -159,7 +159,7 @@ def __init__(self):
creds, user = result
assert user.id == editor_user.id
- assert creds.scopes == WRITE_SCOPES
+ assert creds.scopes == EDIT_SCOPES
assert set(creds.scopes).issubset(editor_user.oauth_scopes)
diff --git a/backend/models/tests/test_user.py b/backend/models/tests/test_user.py
index 6c69b24d9..3274dbee6 100644
--- a/backend/models/tests/test_user.py
+++ b/backend/models/tests/test_user.py
@@ -1,4 +1,4 @@
-from handler.auth.constants import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES
+from handler.auth.constants import EDIT_SCOPES, FULL_SCOPES, WRITE_SCOPES
from models.user import User
@@ -7,8 +7,8 @@ def test_admin(admin_user: User):
def test_editor(editor_user: User):
- assert editor_user.oauth_scopes == WRITE_SCOPES
+ assert editor_user.oauth_scopes == EDIT_SCOPES
def test_user(viewer_user: User):
- assert viewer_user.oauth_scopes == DEFAULT_SCOPES
+ assert viewer_user.oauth_scopes == WRITE_SCOPES
diff --git a/backend/models/user.py b/backend/models/user.py
index da1a1784b..c906d0cb1 100644
--- a/backend/models/user.py
+++ b/backend/models/user.py
@@ -4,7 +4,14 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING
-from handler.auth.constants import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES, Scope
+from config import KIOSK_MODE
+from handler.auth.constants import (
+ EDIT_SCOPES,
+ FULL_SCOPES,
+ READ_SCOPES,
+ WRITE_SCOPES,
+ Scope,
+)
from models.base import BaseModel
from sqlalchemy import TIMESTAMP, Enum, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -45,15 +52,33 @@ class User(BaseModel, SimpleUser):
rom_users: Mapped[list[RomUser]] = relationship(back_populates="user")
collections: Mapped[list[Collection]] = relationship(back_populates="user")
+ @classmethod
+ def kiosk_mode_user(cls) -> User:
+ now = datetime.now(timezone.utc)
+ return cls(
+ id=-1,
+ username="kiosk",
+ role=Role.VIEWER,
+ enabled=True,
+ avatar_path="",
+ last_active=now,
+ last_login=now,
+ created_at=now,
+ updated_at=now,
+ )
+
@property
def oauth_scopes(self) -> list[Scope]:
if self.role == Role.ADMIN:
return FULL_SCOPES
if self.role == Role.EDITOR:
- return WRITE_SCOPES
+ return EDIT_SCOPES
+
+ if KIOSK_MODE:
+ return READ_SCOPES
- return DEFAULT_SCOPES
+ return WRITE_SCOPES
@property
def fs_safe_folder_name(self):
diff --git a/env.template b/env.template
index 31fe2683d..c6705e26d 100644
--- a/env.template
+++ b/env.template
@@ -1,5 +1,6 @@
ROMM_BASE_PATH=/path/to/romm_mock
DEV_MODE=true
+KIOSK_MODE=false
# Gunicorn (optional)
GUNICORN_WORKERS=4 # (2 × CPU cores) + 1
diff --git a/frontend/src/components/Details/ActionBar.vue b/frontend/src/components/Details/ActionBar.vue
index edf51733e..ad2782659 100644
--- a/frontend/src/components/Details/ActionBar.vue
+++ b/frontend/src/components/Details/ActionBar.vue
@@ -6,6 +6,7 @@ import storeDownload from "@/stores/download";
import storeHeartbeat from "@/stores/heartbeat";
import storeConfig from "@/stores/config";
import type { DetailedRom } from "@/stores/roms";
+import storeAuth from "@/stores/auth";
import type { Events } from "@/types/emitter";
import {
getDownloadLink,
@@ -26,6 +27,7 @@ const playInfoIcon = ref("mdi-play");
const qrCodeIcon = ref("mdi-qrcode");
const configStore = storeConfig();
const { config } = storeToRefs(configStore);
+const auth = storeAuth();
const platformSlug = computed(() =>
props.rom.platform_slug in config.value.PLATFORMS_VERSIONS
@@ -128,7 +130,14 @@ async function copyDownloadLink(rom: DetailedRom) {
>
-
+
diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue
index bbb7dcba9..6c3d0fb76 100644
--- a/frontend/src/components/Details/Info/GameInfo.vue
+++ b/frontend/src/components/Details/Info/GameInfo.vue
@@ -1,9 +1,8 @@
@@ -79,15 +80,16 @@ async function logout() {
{{ t("common.profile") }}
{{ t("common.user-interface") }}
@@ -96,18 +98,18 @@ async function logout() {
class="mt-1"
rounded
append-icon="mdi-table-cog"
- :to="{ name: 'libraryManagement' }"
+ :to="{ name: ROUTES.LIBRARY_MANAGEMENT }"
>{{ t("common.library-management") }}
{{ t("common.administration") }}
-
+
-
+
import type { Platform } from "@/stores/platforms";
+import { ROUTES } from "@/plugins/router";
import PlatformIcon from "@/components/common/Platform/Icon.vue";
defineProps<{ platform: Platform }>();
@@ -12,7 +13,7 @@ defineProps<{ platform: Platform }>();
class="bg-toplayer transform-scale"
:class="{ 'on-hover': isHovering }"
:elevation="isHovering ? 20 : 3"
- :to="{ name: 'platform', params: { platform: platform.id } }"
+ :to="{ name: ROUTES.PLATFORM, params: { platform: platform.id } }"
>
diff --git a/frontend/src/components/common/Platform/Dialog/DeletePlatform.vue b/frontend/src/components/common/Platform/Dialog/DeletePlatform.vue
index c5094e42f..3e3cbe1be 100644
--- a/frontend/src/components/common/Platform/Dialog/DeletePlatform.vue
+++ b/frontend/src/components/common/Platform/Dialog/DeletePlatform.vue
@@ -3,11 +3,12 @@ import RDialog from "@/components/common/RDialog.vue";
import platformApi from "@/services/api/platform";
import storePlatforms, { type Platform } from "@/stores/platforms";
import type { Events } from "@/types/emitter";
+import { ROUTES } from "@/plugins/router";
+import PlatformIcon from "@/components/common/Platform/Icon.vue";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
-import PlatformIcon from "@/components/common/Platform/Icon.vue";
import { useI18n } from "vue-i18n";
// Props
@@ -47,7 +48,7 @@ async function deletePlatform() {
return;
});
- await router.push({ name: "home" });
+ await router.push({ name: ROUTES.HOME });
platformsStore.remove(platform.value);
emitter?.emit("refreshDrawer", null);
diff --git a/frontend/src/components/common/Platform/ListItem.vue b/frontend/src/components/common/Platform/ListItem.vue
index be9ace875..63978eb7b 100644
--- a/frontend/src/components/common/Platform/ListItem.vue
+++ b/frontend/src/components/common/Platform/ListItem.vue
@@ -1,5 +1,6 @@