add: endpoint to generate invite link token

This commit is contained in:
zurdi
2025-05-13 17:40:07 +00:00
parent 1103700d00
commit 500ff5e67f
13 changed files with 140 additions and 5 deletions

View File

@@ -31,3 +31,7 @@ class UserSchema(BaseModel):
class Config:
from_attributes = True
class InviteLinkSchema(BaseModel):
token: str

View File

@@ -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

View File

@@ -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:

View File

@@ -62,4 +62,5 @@ FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys())
class TokenPurpose(enum.StrEnum):
INVITE = "invite"
RESET = "reset"

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type InviteLinkSchema = {
link: string;
};

View File

@@ -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<Events>>("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") }}
</v-btn>
<v-btn
prepend-icon="mdi-share"
variant="outlined"
class="text-primary ml-2"
@click="createInviteLink"
>
{{ t("settings.invite-link") }}
</v-btn>
</template>
<template #item.avatar_path="{ item }">

View File

@@ -14,6 +14,7 @@
"group-roms-desc": "Group versions of the same rom together in the gallery",
"home": "Home",
"interface": "Interface",
"invite-link": "Invite link",
"language": "Language",
"main-platform": "Main platform",
"password": "Password",

View File

@@ -1,4 +1,8 @@
import type { MessageResponse, UserSchema } from "@/__generated__";
import type {
MessageResponse,
UserSchema,
InviteLinkSchema,
} from "@/__generated__";
import api from "@/services/api/index";
import type { User } from "@/stores/users";
@@ -22,6 +26,10 @@ async function createUser({
);
}
async function createInviteLink(): Promise<{ data: InviteLinkSchema }> {
return api.get("/users/invite-link", {});
}
async function fetchUsers(): Promise<{ data: UserSchema[] }> {
return api.get("/users");
}
@@ -77,6 +85,7 @@ async function refreshRetroAchievements({
export default {
createUser,
createInviteLink,
fetchUsers,
fetchUser,
fetchCurrentUser,

View File

@@ -50,6 +50,7 @@ export type Events = {
title: string;
};
showCreateUserDialog: null;
showCreateInviteLinkDialog: string;
showEditUserDialog: User;
showDeleteUserDialog: User;
showDeleteSavesDialog: {

View File

@@ -10,7 +10,6 @@ import { storeToRefs } from "pinia";
import { inject, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { ROUTES } from "@/plugins/router";
// Props
const { t } = useI18n();