Make invite token expiration configurable via env var and UI

Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-09 01:43:53 +00:00
parent 413f6a68ab
commit 5f309639af
5 changed files with 68 additions and 13 deletions

View File

@@ -126,6 +126,9 @@ DISABLE_USERPASS_LOGIN: Final[bool] = safe_str_to_bool(
_get_env("DISABLE_USERPASS_LOGIN")
)
DISABLE_SETUP_WIZARD: Final[bool] = safe_str_to_bool(_get_env("DISABLE_SETUP_WIZARD"))
INVITE_TOKEN_EXPIRY_MINUTES: Final[int] = safe_int(
_get_env("INVITE_TOKEN_EXPIRY_MINUTES"), 10
)
# OIDC
OIDC_ENABLED: Final[bool] = safe_str_to_bool(_get_env("OIDC_ENABLED"))

View File

@@ -108,12 +108,16 @@ def add_user(
[],
status_code=status.HTTP_201_CREATED,
)
def create_invite_link(request: Request, role: str) -> InviteLinkSchema:
def create_invite_link(
request: Request, role: str, expiration_minutes: int | None = None
) -> InviteLinkSchema:
"""Create an invite link for a user.
Args:
request (Request): FastAPI Request object
role (str): The role of the user
expiration_minutes (int | None): Token expiration in minutes. Defaults to
the INVITE_TOKEN_EXPIRY_MINUTES environment variable.
Returns:
InviteLinkSchema: Invite link
@@ -136,7 +140,17 @@ def create_invite_link(request: Request, role: str) -> InviteLinkSchema:
detail=msg,
)
token = auth_handler.generate_invite_link_token(request.user, role=role)
if expiration_minutes is not None and expiration_minutes <= 0:
msg = "expiration_minutes must be a positive integer"
log.error(msg)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=msg,
)
token = auth_handler.generate_invite_link_token(
request.user, role=role, expiration_minutes=expiration_minutes
)
return InviteLinkSchema.model_validate({"token": token})

View File

@@ -10,6 +10,7 @@ from passlib.context import CryptContext
from starlette.requests import HTTPConnection
from config import (
INVITE_TOKEN_EXPIRY_MINUTES,
OIDC_CLAIM_ROLES,
OIDC_ENABLED,
OIDC_ROLE_ADMIN,
@@ -35,7 +36,6 @@ 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)
@@ -169,15 +169,20 @@ class AuthHandler:
)
await RedisSessionMiddleware.clear_user_sessions(user.username)
def generate_invite_link_token(self, user: Any, role: str) -> str:
def generate_invite_link_token(
self, user: Any, role: str, expiration_minutes: int | None = None
) -> str:
"""
Generate an invite link token for the user.
Args:
user (Any): The user object.
role (str): The role of the user.
expiration_minutes (int | None): Token expiration in minutes. Defaults to
the INVITE_TOKEN_EXPIRY_MINUTES environment variable.
Returns:
str: The generated invite link token.
"""
expires_in = expiration_minutes if expiration_minutes is not None else INVITE_TOKEN_EXPIRY_MINUTES
now = datetime.now(timezone.utc)
jti = str(uuid.uuid4())
@@ -189,7 +194,7 @@ class AuthHandler:
"iat": int(now.timestamp()),
"exp": int(
(
now + timedelta(minutes=self.invite_link_token_expires_in_minutes)
now + timedelta(minutes=expires_in)
).timestamp()
),
"jti": jti,
@@ -204,7 +209,7 @@ class AuthHandler:
f"Invite 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"
f"invite-jti:{jti}", expires_in * 60, "valid"
)
return token

View File

@@ -11,7 +11,17 @@ const { lgAndUp } = useDisplay();
const show = ref(false);
const fullInviteLink = ref("");
const selectedRole = ref("");
const selectedExpiration = ref<number>(1440);
const roles = ["viewer", "editor", "admin"];
const expirationOptions = [
{ label: "1 hour", value: 60 },
{ label: "6 hours", value: 360 },
{ label: "12 hours", value: 720 },
{ label: "1 day", value: 1440 },
{ label: "3 days", value: 4320 },
{ label: "7 days", value: 10080 },
{ label: "30 days", value: 43200 },
];
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showCreateInviteLinkDialog", () => {
show.value = true;
@@ -19,7 +29,10 @@ emitter?.on("showCreateInviteLinkDialog", () => {
function createInviteLink() {
userApi
.createInviteLink({ role: selectedRole.value })
.createInviteLink({
role: selectedRole.value,
expirationMinutes: selectedExpiration.value,
})
.then(({ data }) => {
emitter?.emit("snackbarShow", {
msg: "Invite link created",
@@ -65,6 +78,20 @@ function closeDialog() {
>{{ role.charAt(0).toUpperCase() + role.slice(1) }}
</v-btn>
</v-btn-toggle>
</v-row>
<v-row class="justify-center pa-2" no-gutters>
<v-select
v-model="selectedExpiration"
:items="expirationOptions"
item-title="label"
item-value="value"
label="Expires in"
variant="outlined"
density="compact"
hide-details
class="ma-1"
style="max-width: 200px"
/>
<v-btn-toggle class="text-primary ma-1" divided>
<v-btn
:disabled="!selectedRole"

View File

@@ -24,12 +24,18 @@ async function createUser({
return api.post<UserSchema>("/users", payload);
}
async function createInviteLink({ role }: { role: string }) {
return api.post<InviteLinkSchema>(
"/users/invite-link",
{},
{ params: { role } },
);
async function createInviteLink({
role,
expirationMinutes,
}: {
role: string;
expirationMinutes?: number;
}) {
const params: Record<string, string | number> = { role };
if (expirationMinutes !== undefined) {
params.expiration_minutes = expirationMinutes;
}
return api.post<InviteLinkSchema>("/users/invite-link", {}, { params });
}
async function registerUser(