feat(frontend): start settings
This commit is contained in:
		
							parent
							
								
									0c78cd25b0
								
							
						
					
					
						commit
						c179669799
					
				
					 13 changed files with 301 additions and 17 deletions
				
			
		|  | @ -17,6 +17,7 @@ | |||
| 		"@sveltejs/vite-plugin-svelte": "^4.0.0", | ||||
| 		"@sveltestrap/sveltestrap": "^6.2.7", | ||||
| 		"@types/eslint": "^9.6.0", | ||||
| 		"@types/luxon": "^3.4.2", | ||||
| 		"@types/markdown-it": "^14.1.2", | ||||
| 		"@types/sanitize-html": "^2.13.0", | ||||
| 		"bootstrap": "^5.3.3", | ||||
|  | @ -38,6 +39,7 @@ | |||
| 	"dependencies": { | ||||
| 		"@fontsource/firago": "^5.1.0", | ||||
| 		"bootstrap-icons": "^1.11.3", | ||||
| 		"luxon": "^3.5.0", | ||||
| 		"markdown-it": "^14.1.0", | ||||
| 		"sanitize-html": "^2.13.1", | ||||
| 		"tslog": "^4.9.3" | ||||
|  |  | |||
							
								
								
									
										17
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -14,6 +14,9 @@ importers: | |||
|       bootstrap-icons: | ||||
|         specifier: ^1.11.3 | ||||
|         version: 1.11.3 | ||||
|       luxon: | ||||
|         specifier: ^3.5.0 | ||||
|         version: 3.5.0 | ||||
|       markdown-it: | ||||
|         specifier: ^14.1.0 | ||||
|         version: 14.1.0 | ||||
|  | @ -39,6 +42,9 @@ importers: | |||
|       '@types/eslint': | ||||
|         specifier: ^9.6.0 | ||||
|         version: 9.6.1 | ||||
|       '@types/luxon': | ||||
|         specifier: ^3.4.2 | ||||
|         version: 3.4.2 | ||||
|       '@types/markdown-it': | ||||
|         specifier: ^14.1.2 | ||||
|         version: 14.1.2 | ||||
|  | @ -590,6 +596,9 @@ packages: | |||
|   '@types/linkify-it@5.0.0': | ||||
|     resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} | ||||
| 
 | ||||
|   '@types/luxon@3.4.2': | ||||
|     resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} | ||||
| 
 | ||||
|   '@types/markdown-it@14.1.2': | ||||
|     resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} | ||||
| 
 | ||||
|  | @ -1063,6 +1072,10 @@ packages: | |||
|   lodash.merge@4.6.2: | ||||
|     resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} | ||||
| 
 | ||||
|   luxon@3.5.0: | ||||
|     resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} | ||||
|     engines: {node: '>=12'} | ||||
| 
 | ||||
|   magic-string@0.30.12: | ||||
|     resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} | ||||
| 
 | ||||
|  | @ -1800,6 +1813,8 @@ snapshots: | |||
| 
 | ||||
|   '@types/linkify-it@5.0.0': {} | ||||
| 
 | ||||
|   '@types/luxon@3.4.2': {} | ||||
| 
 | ||||
|   '@types/markdown-it@14.1.2': | ||||
|     dependencies: | ||||
|       '@types/linkify-it': 5.0.0 | ||||
|  | @ -2302,6 +2317,8 @@ snapshots: | |||
| 
 | ||||
|   lodash.merge@4.6.2: {} | ||||
| 
 | ||||
|   luxon@3.5.0: {} | ||||
| 
 | ||||
|   magic-string@0.30.12: | ||||
|     dependencies: | ||||
|       '@jridgewell/sourcemap-codec': 1.5.0 | ||||
|  |  | |||
|  | @ -23,3 +23,20 @@ | |||
| @import "@fontsource/firago/400-italic.css"; | ||||
| @import "@fontsource/firago/700.css"; | ||||
| @import "@fontsource/firago/700-italic.css"; | ||||
| 
 | ||||
| // This is necessary for line breaks in translation strings to show up. Don't ask me why | ||||
| .text-has-newline { | ||||
| 	white-space: pre-line; | ||||
| } | ||||
| 
 | ||||
| // Add breakpoint-dependent w-{size} utilities | ||||
| // Source: https://stackoverflow.com/questions/47760132/any-way-to-get-breakpoint-specific-width-classes | ||||
| @each $breakpoint in map-keys(bootstrap.$grid-breakpoints) { | ||||
| 	@each $size, $length in (25: 25%, 50: 50%, 75: 75%, 100: 100%) { | ||||
| 		@include bootstrap.media-breakpoint-up($breakpoint) { | ||||
| 			.w-#{$breakpoint}-#{$size} { | ||||
| 				width: $length !important; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -52,3 +52,15 @@ export type ValidationError = { | |||
| 	allowed_values?: any[]; | ||||
| 	actual_value?: any; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Returns the first error for the value `key` in `error`. | ||||
|  * @param error The error object to traverse. | ||||
|  * @param key The JSON key to find. | ||||
|  */ | ||||
| export const firstErrorFor = (error: RawApiError, key: string): ValidationError | undefined => { | ||||
| 	if (!error.errors) return undefined; | ||||
| 	const field = error.errors.find((e) => e.key == key); | ||||
| 	if (!field?.errors) return undefined; | ||||
| 	return field.errors.length != 0 ? field.errors[0] : undefined; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,14 +1,14 @@ | |||
| <script lang="ts"> | ||||
| 	import { DEFAULT_AVATAR } from "$lib"; | ||||
| 
 | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean }; | ||||
| 	let { url, alt, lazyLoad }: Props = $props(); | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean; width?: number }; | ||||
| 	let { url, alt, lazyLoad, width }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <img | ||||
| 	class="rounded-circle img-fluid" | ||||
| 	src={url || DEFAULT_AVATAR} | ||||
| 	{alt} | ||||
| 	width={200} | ||||
| 	width={width || 200} | ||||
| 	loading={lazyLoad ? "lazy" : "eager"} | ||||
| /> | ||||
|  |  | |||
|  | @ -4,17 +4,19 @@ | |||
| 	import { t } from "$lib/i18n"; | ||||
| 	import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte"; | ||||
| 
 | ||||
| 	type Props = { headerElem?: string; error: RawApiError }; | ||||
| 	let { headerElem, error }: Props = $props(); | ||||
| 	type Props = { showHeader?: boolean; headerElem?: string; error: RawApiError }; | ||||
| 	let { showHeader, headerElem, error }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:element this={headerElem ?? "h4"}> | ||||
| 	{#if error.code === ErrorCode.BadRequest} | ||||
| 		{$t("error.bad-request-header")} | ||||
| 	{:else} | ||||
| 		{$t("error.generic-header")} | ||||
| 	{/if} | ||||
| </svelte:element> | ||||
| {#if showHeader !== false} | ||||
| 	<svelte:element this={headerElem ?? "h4"}> | ||||
| 		{#if error.code === ErrorCode.BadRequest} | ||||
| 			{$t("error.bad-request-header")} | ||||
| 		{:else} | ||||
| 			{$t("error.generic-header")} | ||||
| 		{/if} | ||||
| 	</svelte:element> | ||||
| {/if} | ||||
| <p>{errorDescription($t, error.code)}</p> | ||||
| {#if error.errors} | ||||
| 	<details> | ||||
|  |  | |||
|  | @ -16,7 +16,8 @@ | |||
| 	}, | ||||
| 	"title": { | ||||
| 		"log-in": "Log in", | ||||
| 		"welcome": "Welcome" | ||||
| 		"welcome": "Welcome", | ||||
| 		"settings": "Settings" | ||||
| 	}, | ||||
| 	"auth": { | ||||
| 		"log-in-form-title": "Log in with email", | ||||
|  | @ -59,5 +60,32 @@ | |||
| 		"validation-reason": "Reason", | ||||
| 		"validation-generic": "The value you entered is not allowed here. Reason", | ||||
| 		"extra-info-header": "Extra error information" | ||||
| 	} | ||||
| 	}, | ||||
| 	"settings": { | ||||
| 		"general-information-tab": "General information", | ||||
| 		"your-profile-tab": "Your profile", | ||||
| 		"members-tab": "Members", | ||||
| 		"authentication-tab": "Authentication", | ||||
| 		"export-tab": "Export your data", | ||||
| 		"change-username-button": "Change username", | ||||
| 		"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.", | ||||
| 		"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}", | ||||
| 		"change-avatar-link": "Change your avatar here", | ||||
| 		"new-username": "New username", | ||||
| 		"table-role": "Role", | ||||
| 		"table-custom-preferences": "Custom preferences", | ||||
| 		"table-member-list-hidden": "Member list hidden?", | ||||
| 		"table-member-count": "Member count", | ||||
| 		"table-created-at": "Account created at", | ||||
| 		"table-id": "Your ID", | ||||
| 		"table-title": "Account information", | ||||
| 		"force-log-out-title": "Log out everywhere", | ||||
| 		"force-log-out-button": "Force log out", | ||||
| 		"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.", | ||||
| 		"log-out-title": "Log out", | ||||
| 		"log-out-hint": "Use this button to log out on this device only.", | ||||
| 		"log-out-button": "Log out" | ||||
| 	}, | ||||
| 	"yes": "Yes", | ||||
| 	"no": "No" | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| // place files you want to import through the `$lib` alias in this folder.
 | ||||
| 
 | ||||
| import type { Cookies } from "@sveltejs/kit"; | ||||
| import { DateTime } from "luxon"; | ||||
| 
 | ||||
| export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; | ||||
| 
 | ||||
|  | @ -10,3 +11,6 @@ export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME | |||
| 
 | ||||
| // TODO: change this to something we actually clearly have the rights to use
 | ||||
| export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp"; | ||||
| 
 | ||||
| export const idTimestamp = (id: string) => | ||||
| 	DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000); | ||||
|  |  | |||
|  | @ -1,15 +1,15 @@ | |||
| import { clearToken, TOKEN_COOKIE_NAME } from "$lib"; | ||||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode } from "$api/error"; | ||||
| import type { Meta, User } from "$api/models"; | ||||
| import type { Meta, MeUser } from "$api/models"; | ||||
| import log from "$lib/log"; | ||||
| import type { LayoutServerLoad } from "./$types"; | ||||
| 
 | ||||
| export const load = (async ({ fetch, cookies }) => { | ||||
| 	let meUser: User | null = null; | ||||
| 	let meUser: MeUser | null = null; | ||||
| 	if (cookies.get(TOKEN_COOKIE_NAME)) { | ||||
| 		try { | ||||
| 			meUser = await apiRequest<User>("GET", "/users/@me", { fetch, cookies }); | ||||
| 			meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies }); | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies); | ||||
| 			else log.error("Could not fetch /users/@me and token has not expired:", e); | ||||
|  |  | |||
							
								
								
									
										8
									
								
								Foxnouns.Frontend/src/routes/settings/+layout.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Foxnouns.Frontend/src/routes/settings/+layout.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| import { redirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const load = async ({ parent }) => { | ||||
| 	const data = await parent(); | ||||
| 	if (!data.meUser) redirect(303, "/auth/log-in"); | ||||
| 
 | ||||
| 	return { user: data.meUser! }; | ||||
| }; | ||||
							
								
								
									
										44
									
								
								Foxnouns.Frontend/src/routes/settings/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								Foxnouns.Frontend/src/routes/settings/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | |||
| <script lang="ts"> | ||||
| 	import type { Snippet } from "svelte"; | ||||
| 	import { page } from "$app/stores"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Nav, NavLink } from "@sveltestrap/sveltestrap"; | ||||
| 
 | ||||
| 	type Props = { children: Snippet }; | ||||
| 	let { children }: Props = $props(); | ||||
| 
 | ||||
| 	const isActive = (path: string | string[], prefix: boolean = false) => | ||||
| 		typeof path === "string" | ||||
| 			? prefix | ||||
| 				? $page.url.pathname.startsWith(path) | ||||
| 				: $page.url.pathname === path | ||||
| 			: prefix | ||||
| 				? path.some((p) => $page.url.pathname.startsWith(p)) | ||||
| 				: path.some((p) => $page.url.pathname === p); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("title.settings")} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<Nav pills justified fill class="flex-column flex-md-row mb-2"> | ||||
| 		<NavLink active={isActive(["/settings", "/settings/force-log-out"])} href="/settings"> | ||||
| 			{$t("settings.general-information-tab")} | ||||
| 		</NavLink> | ||||
| 		<NavLink active={isActive("/settings/profile", true)} href="/settings/profile"> | ||||
| 			{$t("settings.your-profile-tab")} | ||||
| 		</NavLink> | ||||
| 		<NavLink active={isActive("/settings/members", true)} href="/settings/members"> | ||||
| 			{$t("settings.members-tab")} | ||||
| 		</NavLink> | ||||
| 		<NavLink active={isActive("/settings/auth", true)} href="/settings/auth"> | ||||
| 			{$t("settings.authentication-tab")} | ||||
| 		</NavLink> | ||||
| 		<NavLink active={isActive("/settings/export")} href="/settings/export"> | ||||
| 			{$t("settings.export-tab")} | ||||
| 		</NavLink> | ||||
| 	</Nav> | ||||
| 
 | ||||
| 	{@render children?.()} | ||||
| </div> | ||||
							
								
								
									
										37
									
								
								Foxnouns.Frontend/src/routes/settings/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								Foxnouns.Frontend/src/routes/settings/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| import { fastRequest } from "$api"; | ||||
| import ApiError, { ErrorCode, type RawApiError } from "$api/error"; | ||||
| import { clearToken } from "$lib"; | ||||
| import { redirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const actions = { | ||||
| 	logout: async ({ cookies }) => { | ||||
| 		clearToken(cookies); | ||||
| 		redirect(303, "/"); | ||||
| 	}, | ||||
| 	changeUsername: async ({ request, fetch, cookies }) => { | ||||
| 		const body = await request.formData(); | ||||
| 		const username = body.get("username") as string | null; | ||||
| 		if (username == null) | ||||
| 			return { | ||||
| 				error: { | ||||
| 					status: 403, | ||||
| 					code: ErrorCode.BadRequest, | ||||
| 					message: "Invalid username", | ||||
| 				} as RawApiError, | ||||
| 				ok: false, | ||||
| 			}; | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest("PATCH", "/users/@me", { | ||||
| 				fetch, | ||||
| 				cookies, | ||||
| 				body: { username }, | ||||
| 			}); | ||||
| 
 | ||||
| 			return { error: null, ok: true }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { error: e.obj, ok: false }; | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
							
								
								
									
										113
									
								
								Foxnouns.Frontend/src/routes/settings/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								Foxnouns.Frontend/src/routes/settings/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,113 @@ | |||
| <script lang="ts"> | ||||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Button, FormGroup, Icon, Input, InputGroup, Label } from "@sveltestrap/sveltestrap"; | ||||
| 	import Avatar from "$components/Avatar.svelte"; | ||||
| 	import { firstErrorFor } from "$api/error"; | ||||
| 	import Error from "$components/Error.svelte"; | ||||
| 	import { idTimestamp } from "$lib"; | ||||
| 	import { DateTime } from "luxon"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
| 
 | ||||
| 	let usernameError = $derived(form?.error ? firstErrorFor(form.error, "username") : undefined); | ||||
| 	let createdAt = $derived(idTimestamp(data.user.id)); | ||||
| </script> | ||||
| 
 | ||||
| <h3>{$t("settings.general-information-tab")}</h3> | ||||
| 
 | ||||
| <div class="row mb-3"> | ||||
| 	<div class="col-md-9"> | ||||
| 		<h5>Change your username</h5> | ||||
| 		<form method="POST" action="?/changeUsername"> | ||||
| 			<FormGroup class="mb-3"> | ||||
| 				<InputGroup class="m-1 mt-3 w-md-75"> | ||||
| 					<Input type="text" value={data.user.username} name="username" required /> | ||||
| 					<Button type="submit" color="secondary">{$t("settings.change-username-button")}</Button> | ||||
| 				</InputGroup> | ||||
| 			</FormGroup> | ||||
| 			{#if form?.ok} | ||||
| 				<p class="text-success-emphasis"> | ||||
| 					<Icon name="check-circle-fill" /> Successfully changed your username! | ||||
| 				</p> | ||||
| 			{:else if usernameError} | ||||
| 				<p class="text-danger-emphasis text-has-newline"> | ||||
| 					<Icon name="exclamation-triangle-fill" /> | ||||
| 					{$t("settings.username-update-error", { message: usernameError.message })} | ||||
| 				</p> | ||||
| 			{:else if form?.error} | ||||
| 				<Error showHeader={false} error={form?.error} /> | ||||
| 			{/if} | ||||
| 		</form> | ||||
| 		<p class="text-muted text-has-newline"> | ||||
| 			<Icon name="info-circle-fill" aria-hidden /> | ||||
| 			{$t("settings.username-change-hint")} | ||||
| 		</p> | ||||
| 	</div> | ||||
| 	<div class="col-md-3 text-center"> | ||||
| 		<h5>Avatar</h5> | ||||
| 		<Avatar | ||||
| 			url={data.user.avatar_url} | ||||
| 			alt={$t("avatar-tooltip", { name: "@" + data.user.username })} | ||||
| 		/> | ||||
| 		<p class="mt-2"> | ||||
| 			<a href="/settings/profile">{$t("settings.change-avatar-link")}</a> | ||||
| 		</p> | ||||
| 	</div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="mb-3"> | ||||
| 	<h4>{$t("settings.log-out-title")}</h4> | ||||
| 	<p>{$t("settings.log-out-hint")}</p> | ||||
| 	<form method="POST" action="?/logout"> | ||||
| 		<Button color="secondary" type="submit">{$t("settings.log-out-button")}</Button> | ||||
| 	</form> | ||||
| </div> | ||||
| 
 | ||||
| <div class="mb-3"> | ||||
| 	<h4>{$t("settings.force-log-out-title")}</h4> | ||||
| 	<p>{$t("settings.force-log-out-hint")}</p> | ||||
| 	<a class="btn btn-danger" href="/settings/force-log-out">{$t("settings.force-log-out-button")}</a> | ||||
| </div> | ||||
| 
 | ||||
| <div> | ||||
| 	<h4>{$t("settings.table-title")}</h4> | ||||
| 
 | ||||
| 	<table class="table table-striped table-hover table-bordered"> | ||||
| 		<tbody> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-id")}</th> | ||||
| 				<td> | ||||
| 					<code>{data.user.id}</code> | ||||
| 				</td> | ||||
| 			</tr> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-created-at")}</th> | ||||
| 				<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td> | ||||
| 			</tr> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-member-count")}</th> | ||||
| 				<td> | ||||
| 					{data.user.members.length}/{data.meta.limits.member_count} | ||||
| 				</td> | ||||
| 			</tr> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-member-list-hidden")}</th> | ||||
| 				<td>{data.user.member_list_hidden ? $t("yes") : $t("no")}</td> | ||||
| 			</tr> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-custom-preferences")}</th> | ||||
| 				<td> | ||||
| 					{Object.keys(data.user.custom_preferences).length}/{data.meta.limits.custom_preferences} | ||||
| 				</td> | ||||
| 			</tr> | ||||
| 			<tr> | ||||
| 				<th scope="row">{$t("settings.table-role")}</th> | ||||
| 				<td> | ||||
| 					<code>{data.user.role}</code> | ||||
| 				</td> | ||||
| 			</tr> | ||||
| 		</tbody> | ||||
| 	</table> | ||||
| </div> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue