Add Become Player admin feature

master
John Montagu, the 4th Earl of Sandvich 2025-05-13 23:13:44 -07:00
parent cb9b6535c1
commit e98bd1f647
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
19 changed files with 464 additions and 152 deletions

View File

@ -1400,12 +1400,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -2035,56 +2029,6 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
@ -6399,6 +6343,62 @@
"vue": ">= 3.2.0"
}
},
"node_modules/radix-vue/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/radix-vue/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/nanoid": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",

View File

@ -421,3 +421,69 @@ tr:last-child > td:first-child {
tr:last-child > td:last-child {
border-bottom-right-radius: 4px;
}
.sidebar-container {
display: flex;
gap: 16px;
justify-content: center;
}
.sidebar-container nav.sidebar {
display: flex;
justify-content: end;
}
.sidebar-container .view {
width: 60%;
}
nav.sidebar h3 {
text-transform: uppercase;
color: var(--overlay-0);
padding: 0 8px;
font-size: 8pt;
}
nav.sidebar > .categories {
display: flex;
flex-direction: column;
width: 192px;
gap: 4px;
}
nav.sidebar a.tab {
font-size: 11pt;
color: var(--overlay-0);
padding: 6px 10px;
font-weight: 500;
border-radius: 4px;
}
nav.sidebar a.tab:hover {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar a.tab.router-link-exact-active {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar button {
font-size: 11pt;
font-weight: 500;
padding: 6px 10px;
background-color: transparent;
color: var(--overlay-0);
}
nav.sidebar button:hover {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar button.destructive-on-hover:hover {
background-color: var(--destructive);
color: var(--base);
}

View File

@ -22,10 +22,12 @@ export type { EventWithPlayerSchema } from './models/EventWithPlayerSchema';
export type { EventWithPlayerSchemaList } from './models/EventWithPlayerSchemaList';
export type { GetEventPlayersResponse } from './models/GetEventPlayersResponse';
export type { GetMatchQuery } from './models/GetMatchQuery';
export type { GetUserResponse } from './models/GetUserResponse';
export type { MatchSchema } from './models/MatchSchema';
export type { PlayerEventRolesSchema } from './models/PlayerEventRolesSchema';
export type { PlayerRoleSchema } from './models/PlayerRoleSchema';
export type { PlayerSchema } from './models/PlayerSchema';
export type { PlayerSchemaList } from './models/PlayerSchemaList';
export type { PlayerTeamAvailabilityRoleSchema } from './models/PlayerTeamAvailabilityRoleSchema';
export type { PutScheduleForm } from './models/PutScheduleForm';
export type { RoleSchema } from './models/RoleSchema';

View File

@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlayerSchema } from './PlayerSchema';
export type GetUserResponse = {
isAdmin?: boolean;
realUser: (PlayerSchema | null);
steamId: string;
username: string;
};

View File

@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export type PlayerSchema = {
isAdmin?: boolean;
steamId: string;
username: string;
};

View File

@ -0,0 +1,6 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlayerSchema } from './PlayerSchema';
export type PlayerSchemaList = Array<PlayerSchema>;

View File

@ -6,6 +6,7 @@ import type { RoleSchema } from './RoleSchema';
export type ViewTeamMembersResponse = {
availability: Array<number>;
createdAt: string;
isAdmin?: boolean;
isTeamLeader?: boolean;
playtime: number;
roles: Array<RoleSchema>;

View File

@ -12,8 +12,10 @@ import type { EventSchema } from '../models/EventSchema';
import type { EventWithPlayerSchema } from '../models/EventWithPlayerSchema';
import type { EventWithPlayerSchemaList } from '../models/EventWithPlayerSchemaList';
import type { GetEventPlayersResponse } from '../models/GetEventPlayersResponse';
import type { GetUserResponse } from '../models/GetUserResponse';
import type { MatchSchema } from '../models/MatchSchema';
import type { PlayerSchema } from '../models/PlayerSchema';
import type { PlayerSchemaList } from '../models/PlayerSchemaList';
import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { SetUsernameJson } from '../models/SetUsernameJson';
import type { SubmitMatchJson } from '../models/SubmitMatchJson';
@ -279,10 +281,10 @@ export class DefaultService {
}
/**
* get_user <GET>
* @returns PlayerSchema OK
* @returns GetUserResponse OK
* @throws ApiError
*/
public getUser(): CancelablePromise<PlayerSchema> {
public getUser(): CancelablePromise<GetUserResponse> {
return this.httpRequest.request({
method: 'GET',
url: '/api/login/get-user',
@ -518,28 +520,6 @@ export class DefaultService {
},
});
}
/**
* delete_team <DELETE>
* @param teamId
* @returns any OK
* @throws ApiError
*/
public deleteTeam(
teamId: number,
): CancelablePromise<any> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/team/id/{team_id}/',
path: {
'team_id': teamId,
},
errors: {
403: `Forbidden`,
404: `Not Found`,
422: `Unprocessable Content`,
},
});
}
/**
* view_team <GET>
* @param teamId
@ -801,6 +781,54 @@ export class DefaultService {
},
});
}
/**
* get_all_users <GET>
* @returns PlayerSchemaList OK
* @throws ApiError
*/
public getAllUsers(): CancelablePromise<PlayerSchemaList> {
return this.httpRequest.request({
method: 'GET',
url: '/api/user/all',
errors: {
422: `Unprocessable Content`,
},
});
}
/**
* unset_doas <DELETE>
* @returns void
* @throws ApiError
*/
public unsetDoas(): CancelablePromise<void> {
return this.httpRequest.request({
method: 'DELETE',
url: '/api/user/doas',
errors: {
422: `Unprocessable Content`,
},
});
}
/**
* set_doas <PUT>
* @param steamId
* @returns PlayerSchema OK
* @throws ApiError
*/
public setDoas(
steamId: string,
): CancelablePromise<PlayerSchema> {
return this.httpRequest.request({
method: 'PUT',
url: '/api/user/doas/{steam_id}',
path: {
'steam_id': steamId,
},
errors: {
422: `Unprocessable Content`,
},
});
}
/**
* set_username <POST>
* @param requestBody

View File

@ -19,6 +19,9 @@ function logout() {
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger className="profile-button no-border">
<span class="aside" v-if="authStore.realUser">
{{ authStore.realUser?.username }}, disguised as
</span>
{{ authStore.username }}
<i class="bi bi-chevron-down" />
</DropdownMenuTrigger>
@ -40,6 +43,14 @@ function logout() {
</button>
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem v-if="authStore.isAdmin">
<RouterLink class="button" :to="{ 'name': 'admin' }">
<button>
<i class="bi bi-person-check margin" />
Super secret admin stuff!
</button>
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem>
<button class="destructive" @click="logout">
<i class="bi bi-box-arrow-right margin" />

View File

@ -13,6 +13,9 @@ import TeamSettingsIntegrationsView from "@/views/TeamSettings/IntegrationsView.
import TeamSettingsInvitesView from "@/views/TeamSettings/InvitesView.vue";
import TeamSettingsMatchesView from "@/views/TeamSettings/MatchesView.vue";
import UserSettingsView from "@/views/UserSettingsView.vue";
import AdminView from "@/views/AdminView.vue";
import AdminGeneralView from "@/views/Admin/GeneralView.vue";
import AdminDoasView from "@/views/Admin/DoasView.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -84,6 +87,23 @@ const router = createRouter({
name: "user-settings",
component: UserSettingsView,
},
{
path: "/admin",
name: "admin",
component: AdminView,
children: [
{
path: "",
name: "admin/",
component: AdminGeneralView,
},
{
path: "doas",
name: "admin/doas",
component: AdminDoasView,
},
],
},
]
});

View File

@ -2,18 +2,20 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { useClientStore } from "./client";
import { useRouter, type LocationQuery } from "vue-router";
import { type PlayerSchema } from "@/client";
import { type GetUserResponse, type PlayerSchema } from "@/client";
export const useAuthStore = defineStore("auth", () => {
const clientStore = useClientStore();
const client = clientStore.client;
const user = ref<PlayerSchema | null>(null);
const user = ref<GetUserResponse | null>(null);
const steamId = ref("");
const username = ref("");
const isLoggedIn = ref(false);
const isRegistering = ref(false);
const hasCheckedAuth = ref(false);
const isAdmin = ref(false);
const realUser = ref<PlayerSchema | null>(null);
const router = useRouter();
@ -35,6 +37,9 @@ export const useAuthStore = defineStore("auth", () => {
steamId.value = response.steamId;
username.value = response.username;
user.value = response;
isAdmin.value = response.isAdmin || (response.realUser?.isAdmin ?? false);
realUser.value = response.realUser ?? null;
return response;
},
undefined,
@ -76,13 +81,48 @@ export const useAuthStore = defineStore("auth", () => {
});
}
async function getAllUsers() {
return client.default.getAllUsers();
}
async function setDoas(doasSteamId: string) {
return client.default.setDoas(doasSteamId)
.then((response) => {
if (user.value) {
realUser.value = {
steamId: user.value.steamId,
username: user.value.username,
isAdmin: user.value.isAdmin,
};
}
steamId.value = response.steamId;
username.value = response.username;
});
}
async function unsetDoas() {
return client.default.unsetDoas()
.then((_) => {
if (realUser.value) {
steamId.value = realUser.value.steamId;
username.value = realUser.value.username;
}
realUser.value = null;
});
}
return {
steamId,
username,
isAdmin,
realUser,
isLoggedIn,
hasCheckedAuth,
isRegistering,
getUser,
getAllUsers,
setDoas,
unsetDoas,
login,
logout,
setUsername,

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { type PlayerSchema, type PlayerSchemaList } from "@/client";
import { useAuthStore } from "@/stores/auth";
import { onMounted, ref } from "vue";
//const cookies = useCookies(["doas"], { doNotParse: true, autoUpdateDependencies: true }, universalCookie);
const doas = ref<PlayerSchema | undefined>();
const users = ref<PlayerSchemaList>([]);
const authStore = useAuthStore();
onMounted(() => {
authStore.getAllUsers()
.then((response) => {
users.value = response;
//doas.value = response.find(user => user.steamId === cookies.get("doas"));
});
});
function setDoas() {
if (doas.value) {
authStore.setDoas(doas.value.steamId);
} else {
authStore.unsetDoas();
}
}
function removeDoas() {
doas.value = undefined;
authStore.unsetDoas();
}
</script>
<template>
<h2>Become User</h2>
<p>
Do as/become a specific user.
</p>
<div>
<div class="form-group margin">
<h3>User</h3>
<v-select
v-model="doas"
:options="users"
label="username"
placeholder="Select a user"
:clearable="true"
:searchable="true"
:close-on-select="true"
:show-search-input="true"
/>
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="destructive-on-hover" @click="removeDoas">
<i class="bi bi-trash" />
</button>
<button class="accent" @click="setDoas">
<i class="bi bi-check" />
Become {{ doas?.username }}
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
</script>
<template>
<h1>Admin Panel</h1>
<p>
This is the admin panel. Only admins can access this page.
</p>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { RouterLink, RouterView } from "vue-router";
</script>
<template>
<main class="sidebar-container admin-panel">
<nav class="sidebar">
<div class="categories">
<h3>Admin Panel</h3>
<RouterLink class="tab" :to="{ name: 'admin/' }">
General
</RouterLink>
<RouterLink class="tab" :to="{ name: 'admin/doas' }">
Become User
</RouterLink>
</div>
</nav>
<div class="view">
<RouterView />
</div>
</main>
</template>

View File

@ -23,7 +23,7 @@ onMounted(() => {
</script>
<template>
<main class="team-settings" v-if="team">
<main class="sidebar-container team-settings" v-if="team">
<nav class="sidebar">
<div class="categories">
<div class="back-link">
@ -59,72 +59,7 @@ onMounted(() => {
</template>
<style scoped>
.team-settings {
display: flex;
gap: 16px;
justify-content: center;
}
.team-settings nav.sidebar {
display: flex;
justify-content: end;
}
.team-settings .view {
width: 60%;
}
.back-link {
padding: 8px 16px;
}
nav.sidebar h3 {
text-transform: uppercase;
color: var(--overlay-0);
padding: 0 8px;
font-size: 8pt;
}
nav.sidebar > .categories {
display: flex;
flex-direction: column;
width: 192px;
gap: 4px;
}
nav.sidebar a.tab {
font-size: 11pt;
color: var(--overlay-0);
padding: 6px 10px;
font-weight: 500;
border-radius: 4px;
}
nav.sidebar a.tab:hover {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar a.tab.router-link-exact-active {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar button {
font-size: 11pt;
font-weight: 500;
padding: 6px 10px;
background-color: transparent;
color: var(--overlay-0);
}
nav.sidebar button:hover {
background-color: var(--crust);
color: var(--text);
}
nav.sidebar button.destructive-on-hover:hover {
background-color: var(--destructive);
color: var(--base);
}
</style>

View File

@ -21,17 +21,36 @@ STEAM_OPENID_URL = "https://steamcommunity.com/openid/login"
def index():
return "test"
class GetUserResponse(PlayerSchema):
real_user: PlayerSchema | None
@classmethod
def from_model(cls, model: Player):
return GetUserResponse(
steam_id=str(model.steam_id),
username=model.username,
is_admin=model.is_admin,
real_user=None,
)
@api_login.get("/get-user")
@spec.validate(
resp=Response(
HTTP_200=PlayerSchema,
HTTP_200=GetUserResponse,
HTTP_401=None,
),
operation_id="get_user"
)
@requires_authentication
def get_user(player: Player, auth_session: AuthSession):
return PlayerSchema.from_model(player).dict(by_alias=True)
if auth_session.player.steam_id != player.steam_id:
return GetUserResponse(
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin,
real_user=PlayerSchema.from_model(auth_session.player)
).dict(by_alias=True)
return GetUserResponse.from_model(player).dict(by_alias=True)
@api_login.post("/authenticate")
def steam_authenticate():

View File

@ -3,6 +3,7 @@ from typing import Optional
from flask import abort, make_response, request
from sqlalchemy.sql.operators import json_path_getitem_op
from app_db import db
from models import auth_session
from models.auth_session import AuthSession
from models.player import Player
from models.player_team import PlayerTeam
@ -13,6 +14,7 @@ def requires_authentication(f):
@wraps(f)
def decorator(*args, **kwargs):
auth = request.cookies.get("auth")
doas = request.cookies.get("doas")
if not auth:
abort(401)
@ -28,6 +30,30 @@ def requires_authentication(f):
player = auth_session.player
kwargs["player"] = player
kwargs["auth_session"] = auth_session
if doas and player.is_admin:
doas_int = int(doas)
if doas_int and doas_int != player.steam_id:
doas_player = db.session.query(
Player
).where(
Player.steam_id == doas_int
).one_or_none()
if doas_player:
kwargs["player"] = doas_player
return f(*args, **kwargs)
return decorator
def requires_admin(f):
@wraps(f)
def decorator(*args, **kwargs):
auth_session: AuthSession | None = kwargs["auth_session"]
if not auth_session or not auth_session.player:
abort(401)
if not auth_session.player.is_admin:
abort(403)
return f(*args, **kwargs)
return decorator

View File

@ -1,6 +1,6 @@
from flask import Blueprint
from flask import Blueprint, abort, make_response
from spectree import Response
from middleware import requires_authentication
from middleware import requires_admin, requires_authentication
from models.player import Player, PlayerSchema
from spec import spec, BaseModel
from app_db import db
@ -23,3 +23,53 @@ def set_username(json: SetUsernameJson, player: Player, **kwargs):
player.username = json.username
db.session.commit()
return PlayerSchema.from_model(player).dict(by_alias=True), 200
@api_user.get("/all")
@spec.validate(
resp=Response(
HTTP_200=list[PlayerSchema],
),
operation_id="get_all_users",
)
@requires_authentication
@requires_admin
def get_all_users(player: Player, **kwargs):
players = db.session.query(Player).all()
return list(map(lambda p: PlayerSchema.from_model(p).dict(by_alias=True), players)), 200
@api_user.put("/doas/<steam_id>")
@spec.validate(
resp=Response(
HTTP_200=PlayerSchema,
),
operation_id="set_doas"
)
@requires_authentication
@requires_admin
def set_doas(steam_id: str, **_):
player = db.session.query(Player).where(
Player.steam_id == steam_id
).one_or_none()
if not player:
abort(404)
resp = make_response(
PlayerSchema.from_model(player).dict(by_alias=True)
)
resp.set_cookie("doas", steam_id, httponly=True)
return resp
@api_user.delete("/doas")
@spec.validate(
resp=Response(
HTTP_204=None,
),
operation_id="unset_doas"
)
@requires_authentication
@requires_admin
def unset_doas(**_):
resp = make_response({ }, 204)
resp.delete_cookie("doas", httponly=True)
return resp