mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
add: endpoint to generate invite link token
This commit is contained in:
@@ -31,3 +31,7 @@ class UserSchema(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InviteLinkSchema(BaseModel):
|
||||
token: str
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -62,4 +62,5 @@ FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys())
|
||||
|
||||
|
||||
class TokenPurpose(enum.StrEnum):
|
||||
INVITE = "invite"
|
||||
RESET = "reset"
|
||||
|
||||
3
frontend/src/__generated__/index.ts
generated
3
frontend/src/__generated__/index.ts
generated
@@ -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';
|
||||
|
||||
8
frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts
generated
Normal file
8
frontend/src/__generated__/models/Body_request_password_reset_api_forgot_password_post.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
9
frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts
generated
Normal file
9
frontend/src/__generated__/models/Body_reset_password_api_reset_password_post.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
8
frontend/src/__generated__/models/InviteLinkSchema.ts
generated
Normal file
8
frontend/src/__generated__/models/InviteLinkSchema.ts
generated
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
1
frontend/src/types/emitter.d.ts
vendored
1
frontend/src/types/emitter.d.ts
vendored
@@ -50,6 +50,7 @@ export type Events = {
|
||||
title: string;
|
||||
};
|
||||
showCreateUserDialog: null;
|
||||
showCreateInviteLinkDialog: string;
|
||||
showEditUserDialog: User;
|
||||
showDeleteUserDialog: User;
|
||||
showDeleteSavesDialog: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user