From 500ff5e67f19246ffff27659d7e73632ea54ff7b Mon Sep 17 00:00:00 2001 From: zurdi Date: Tue, 13 May 2025 17:40:07 +0000 Subject: [PATCH] add: endpoint to generate invite link token --- backend/endpoints/responses/identity.py | 4 ++ backend/endpoints/user.py | 31 ++++++++++++++- backend/handler/auth/base_handler.py | 29 ++++++++++++++ backend/handler/auth/constants.py | 1 + frontend/src/__generated__/index.ts | 3 ++ ...password_reset_api_forgot_password_post.ts | 8 ++++ ..._reset_password_api_reset_password_post.ts | 9 +++++ .../__generated__/models/InviteLinkSchema.ts | 8 ++++ .../Settings/Administration/Users/Table.vue | 38 ++++++++++++++++++- frontend/src/locales/en_US/settings.json | 1 + frontend/src/services/api/user.ts | 11 +++++- frontend/src/types/emitter.d.ts | 1 + frontend/src/views/Auth/Login.vue | 1 - 13 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts create mode 100644 frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts create mode 100644 frontend/src/__generated__/models/InviteLinkSchema.ts diff --git a/backend/endpoints/responses/identity.py b/backend/endpoints/responses/identity.py index c7027f9ca..229f462dc 100644 --- a/backend/endpoints/responses/identity.py +++ b/backend/endpoints/responses/identity.py @@ -31,3 +31,7 @@ class UserSchema(BaseModel): class Config: from_attributes = True + + +class InviteLinkSchema(BaseModel): + token: str diff --git a/backend/endpoints/user.py b/backend/endpoints/user.py index 06a1c26c4..47519f99e 100644 --- a/backend/endpoints/user.py +++ b/backend/endpoints/user.py @@ -7,7 +7,7 @@ from config import ASSETS_BASE_PATH from decorators.auth import protected_route from endpoints.forms.identity import UserForm from endpoints.responses import MessageResponse -from endpoints.responses.identity import UserSchema +from endpoints.responses.identity import InviteLinkSchema, UserSchema from fastapi import Depends, HTTPException, Request, status from handler.auth import auth_handler from handler.auth.constants import Scope @@ -82,6 +82,35 @@ def add_user( return UserSchema.model_validate(db_user_handler.add_user(user)) +@protected_route( + router.get, + "/invite-link", + [], + status_code=status.HTTP_201_CREATED, +) +def invite_link(request: Request) -> InviteLinkSchema: + """Create an invite link for a user. + + Args: + request (Request): FastAPI Request object + + Returns: + InviteLinkSchema: Invite link + """ + + if ( + Scope.USERS_WRITE not in request.auth.scopes + and len(db_user_handler.get_admin_users()) > 0 + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Forbidden", + ) + + token = auth_handler.generate_invite_link_token(request.user) + return InviteLinkSchema.model_validate({"token": token}) + + @protected_route(router.get, "", [Scope.USERS_READ]) def get_users(request: Request) -> list[UserSchema]: """Get all users endpoint diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 4a1cf309e..aa1e688fc 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -22,6 +22,7 @@ class AuthHandler: def __init__(self) -> None: self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") self.reset_passwd_token_expires_in_minutes = 10 + self.invite_link_token_expires_in_minutes = 10 def verify_password(self, plain_password, hashed_password): return self.pwd_context.verify(plain_password, hashed_password) @@ -152,6 +153,34 @@ class AuthHandler: user.id, {"hashed_password": self.get_password_hash(new_password)} ) + def generate_invite_link_token(self, user: Any) -> str: + now = datetime.now(timezone.utc) + + jti = str(uuid.uuid4()) + + to_encode = { + "sub": user.username, + "type": TokenPurpose.INVITE, + "iat": int(now.timestamp()), + "exp": int( + ( + now + timedelta(minutes=self.invite_link_token_expires_in_minutes) + ).timestamp() + ), + "jti": jti, + } + token = jwt.encode( + {"alg": ALGORITHM}, to_encode, OctKey.import_key(ROMM_AUTH_SECRET_KEY) + ) + invite_link = f"{ROMM_BASE_URL}/invite-link?token={token}" + log.info( + f"Invite user link created by {hl(user.username, color=CYAN)}: {hl(invite_link)}" + ) + redis_client.setex( + f"invite-jti:{jti}", self.invite_link_token_expires_in_minutes * 60, "valid" + ) + return token + class OAuthHandler: def __init__(self) -> None: diff --git a/backend/handler/auth/constants.py b/backend/handler/auth/constants.py index a3012e793..a22681017 100644 --- a/backend/handler/auth/constants.py +++ b/backend/handler/auth/constants.py @@ -62,4 +62,5 @@ FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys()) class TokenPurpose(enum.StrEnum): + INVITE = "invite" RESET = "reset" diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 1e9ba3458..4c0458a40 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -6,6 +6,8 @@ export type { AddFirmwareResponse } from './models/AddFirmwareResponse'; export type { Body_add_collection_api_collections_post } from './models/Body_add_collection_api_collections_post'; export type { Body_add_firmware_api_firmware_post } from './models/Body_add_firmware_api_firmware_post'; +export type { Body_request_password_reset_api_forgot_password_post } from './models/Body_request_password_reset_api_forgot_password_post'; +export type { Body_reset_password_api_reset_password_post } from './models/Body_reset_password_api_reset_password_post'; export type { Body_token_api_token_post } from './models/Body_token_api_token_post'; export type { Body_update_collection_api_collections__id__put } from './models/Body_update_collection_api_collections__id__put'; export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom_api_roms__id__put'; @@ -24,6 +26,7 @@ export type { HTTPValidationError } from './models/HTTPValidationError'; export type { IGDBAgeRating } from './models/IGDBAgeRating'; export type { IGDBMetadataPlatform } from './models/IGDBMetadataPlatform'; export type { IGDBRelatedGame } from './models/IGDBRelatedGame'; +export type { InviteLinkSchema } from './models/InviteLinkSchema'; export type { MessageResponse } from './models/MessageResponse'; export type { MetadataSourcesDict } from './models/MetadataSourcesDict'; export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform'; diff --git a/frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts b/frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts new file mode 100644 index 000000000..0b3957b5a --- /dev/null +++ b/frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_request_password_reset_api_forgot_password_post = { + username: string; +}; + diff --git a/frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts b/frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts new file mode 100644 index 000000000..e261c4d61 --- /dev/null +++ b/frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_reset_password_api_reset_password_post = { + token: string; + new_password: string; +}; + diff --git a/frontend/src/__generated__/models/InviteLinkSchema.ts b/frontend/src/__generated__/models/InviteLinkSchema.ts new file mode 100644 index 000000000..0fc0d0d4a --- /dev/null +++ b/frontend/src/__generated__/models/InviteLinkSchema.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type InviteLinkSchema = { + link: string; +}; + diff --git a/frontend/src/components/Settings/Administration/Users/Table.vue b/frontend/src/components/Settings/Administration/Users/Table.vue index 511747874..a581bfcff 100644 --- a/frontend/src/components/Settings/Administration/Users/Table.vue +++ b/frontend/src/components/Settings/Administration/Users/Table.vue @@ -7,12 +7,13 @@ import storeAuth from "@/stores/auth"; import storeUsers, { type User } from "@/stores/users"; import type { Events } from "@/types/emitter"; import { defaultAvatarPath, formatTimestamp, getRoleIcon } from "@/utils"; -import { ROUTES } from "@/plugins/router"; import type { Emitter } from "mitt"; import { storeToRefs } from "pinia"; import { inject, onMounted, ref } from "vue"; +import { useI18n } from "vue-i18n"; // Props +const { t } = useI18n(); const userSearch = ref(""); const emitter = inject>("emitter"); const usersStore = storeUsers(); @@ -72,6 +73,31 @@ function disableUser(user: User) { }); } +function createInviteLink() { + userApi + .createInviteLink() + .then(({ data }) => { + emitter?.emit("snackbarShow", { + msg: "Invite link created", + icon: "mdi-check-circle", + color: "green", + timeout: 5000, + }); + emitter?.emit("showCreateInviteLinkDialog", data.link); + console.log(data); + }) + .catch(({ response, message }) => { + emitter?.emit("snackbarShow", { + msg: `Unable to create invite link: ${ + response?.data?.detail || response?.statusText || message + }`, + icon: "mdi-close-circle", + color: "red", + timeout: 5000, + }); + }); +} + onMounted(() => { userApi .fetchUsers() @@ -114,7 +140,15 @@ onMounted(() => { class="text-primary" @click="emitter?.emit('showCreateUserDialog', null)" > - Add + {{ t("common.add") }} + + + {{ t("settings.invite-link") }}