feat(cabana): add backend user avatars to My account and the top bar
- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
cabana.BackendUserMorphType) with one public attachOne avatar
(cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
server-generated name) and replaces the previous avatar: old rows are
deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
(250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
admin-spa docs
This commit is contained in:
@@ -64,6 +64,20 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.AdminAvatar": {
|
||||||
|
"properties": {
|
||||||
|
"thumb_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminFileCaptionRequest": {
|
"cabana.AdminFileCaptionRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"description": {
|
"description": {
|
||||||
@@ -158,6 +172,14 @@
|
|||||||
},
|
},
|
||||||
"cabana.AdminProfile": {
|
"cabana.AdminProfile": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"avatar": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminAvatar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Avatar is present when the admin has a public avatar."
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2132,6 +2154,136 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/account/avatar": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Remove my avatar",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\\Models\\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"file_data": {
|
||||||
|
"description": "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)",
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"file_data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"413": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Request Entity Too Large"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Upload my avatar",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -2240,7 +2392,7 @@
|
|||||||
},
|
},
|
||||||
"/auth/me": {
|
"/auth/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag and the role.",
|
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|||||||
134
admin/src/api/schema.d.ts
vendored
134
admin/src/api/schema.d.ts
vendored
@@ -84,6 +84,132 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/account/avatar": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Upload my avatar
|
||||||
|
* @description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": {
|
||||||
|
/**
|
||||||
|
* Format: binary
|
||||||
|
* @description Image (jpg, jpeg, png, gif or webp, at most 2 MiB)
|
||||||
|
*/
|
||||||
|
file_data: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Request Entity Too Large */
|
||||||
|
413: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unprocessable Entity */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Remove my avatar
|
||||||
|
* @description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
|
||||||
|
*/
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/auth/login": {
|
"/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -209,7 +335,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Current admin
|
* Current admin
|
||||||
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role.
|
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||||
*/
|
*/
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -4401,6 +4527,10 @@ export interface components {
|
|||||||
};
|
};
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
"cabana.AdminAvatar": {
|
||||||
|
thumb_url?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
"cabana.AdminFileCaptionRequest": {
|
"cabana.AdminFileCaptionRequest": {
|
||||||
description?: string;
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -4428,6 +4558,8 @@ export interface components {
|
|||||||
html: string;
|
html: string;
|
||||||
};
|
};
|
||||||
"cabana.AdminProfile": {
|
"cabana.AdminProfile": {
|
||||||
|
/** @description Avatar is present when the admin has a public avatar. */
|
||||||
|
avatar?: components["schemas"]["cabana.AdminAvatar"];
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
|||||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||||
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
|
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
|
||||||
export type AdminProfile = Schemas['cabana.AdminProfile']
|
export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||||
|
/** An admin's avatar: the public image URL and its 250x250 thumbnail URL. */
|
||||||
|
export type AdminAvatar = Schemas['cabana.AdminAvatar']
|
||||||
/** Body of PUT /account: the only keys My account may change. */
|
/** Body of PUT /account: the only keys My account may change. */
|
||||||
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
|
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
|
||||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import {
|
|||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
|
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
import { currentUser, logout } from '../../state/useAuth'
|
import { currentUser, logout, profileInitials } from '../../state/useAuth'
|
||||||
|
|
||||||
// User menu (design: Top header): a 44px button with the initials avatar,
|
// User menu (design: Top header): a 44px button with the avatar image (the
|
||||||
// the name above the role from /auth/me (both hidden at tablet width) and a
|
// initials when the admin has none), the name above the role from /auth/me
|
||||||
// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and
|
// (both hidden at tablet width) and a chevron; the 280px menu (Reka
|
||||||
// e-mail, a My account item that opens /account and a Wyloguj item. Logging out always ends on the login screen,
|
// DropdownMenu, role menu) shows the name and e-mail, a My account item that
|
||||||
// even when the logout call fails (T-10-23).
|
// opens /account and a Wyloguj item. Logging out always ends on the login
|
||||||
|
// screen, even when the logout call fails (T-10-23).
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
@@ -30,20 +31,10 @@ const displayName = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** First and last name initials, else the login's first letters. */
|
/** First and last name initials, else the login's first letters. */
|
||||||
const initials = computed(() => {
|
const initials = computed(() => profileInitials(currentUser.value))
|
||||||
const user = currentUser.value
|
|
||||||
if (!user) {
|
/** The avatar thumbnail, else the image; '' shows the initials. */
|
||||||
return ''
|
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
|
||||||
}
|
|
||||||
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
|
||||||
if (parts.length > 0) {
|
|
||||||
return parts
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part.charAt(0).toUpperCase())
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
return user.login.slice(0, 2).toUpperCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
const roleName = computed(() => currentUser.value?.role?.name ?? '')
|
||||||
|
|
||||||
@@ -66,7 +57,15 @@ async function onLogout(): Promise<void> {
|
|||||||
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
|
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
|
||||||
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
|
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
v-if="avatarSrc"
|
||||||
|
:src="avatarSrc"
|
||||||
|
alt=""
|
||||||
|
data-user-avatar
|
||||||
|
class="size-[34px] shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
|
v-else
|
||||||
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-user-initials
|
data-user-initials
|
||||||
@@ -86,7 +85,15 @@ async function onLogout(): Promise<void> {
|
|||||||
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 px-2 py-2">
|
<div class="flex items-center gap-3 px-2 py-2">
|
||||||
|
<img
|
||||||
|
v-if="avatarSrc"
|
||||||
|
:src="avatarSrc"
|
||||||
|
alt=""
|
||||||
|
data-user-avatar
|
||||||
|
class="size-10 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
|
v-else
|
||||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>{{ initials }}</span
|
>{{ initials }}</span
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ export async function me(): Promise<AdminProfile | null> {
|
|||||||
return user.value
|
return user.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First and last name initials, else the login's first two letters. */
|
||||||
|
export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_name' | 'login'> | null): string {
|
||||||
|
if (!user) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
return user.login.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||||
export function setCurrentUser(profile: AdminProfile): void {
|
export function setCurrentUser(profile: AdminProfile): void {
|
||||||
user.value = profile
|
user.value = profile
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { CircleAlert } from '@lucide/vue'
|
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { AdminAccountRequest } from '../api/types'
|
import type { AdminAccountRequest } from '../api/types'
|
||||||
import { t } from '../app/i18n'
|
import { t } from '../app/i18n'
|
||||||
@@ -8,15 +8,19 @@ import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
|||||||
import { controlClass } from '../components/form/control'
|
import { controlClass } from '../components/form/control'
|
||||||
import { fieldErrors, focusField } from '../components/form/formState'
|
import { fieldErrors, focusField } from '../components/form/formState'
|
||||||
import Button from '../components/ui/Button.vue'
|
import Button from '../components/ui/Button.vue'
|
||||||
import { currentUser, setCurrentUser } from '../state/useAuth'
|
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../components/ui/confirm'
|
||||||
|
import { currentUser, profileInitials, setCurrentUser } from '../state/useAuth'
|
||||||
import { showToast } from '../state/useToasts'
|
import { showToast } from '../state/useToasts'
|
||||||
|
|
||||||
// My account (WinterCMS Users::myaccount): the signed-in admin edits their
|
// My account (WinterCMS Users::myaccount): the signed-in admin edits their
|
||||||
// own first name, last name, email and subtitle, and changes the password
|
// own first name, last name, email and subtitle, and changes the password
|
||||||
// with the current one. Login is shown read-only. PUT /account takes exactly
|
// with the current one. Login is shown read-only. PUT /account takes exactly
|
||||||
// the allowed keys; the password keys are sent only when a new password is
|
// the allowed keys; the password keys are sent only when a new password is
|
||||||
// typed. Every success passes the returned profile to setCurrentUser, so the
|
// typed. The avatar is uploaded (POST /account/avatar, one file_data part)
|
||||||
// top bar updates without a reload.
|
// and removed (DELETE /account/avatar, after a confirmation) on its own.
|
||||||
|
// Every success passes the returned profile to setCurrentUser, so the top
|
||||||
|
// bar updates without a reload.
|
||||||
const ID_PREFIX = 'account'
|
const ID_PREFIX = 'account'
|
||||||
|
|
||||||
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
|
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
|
||||||
@@ -78,6 +82,90 @@ const busy = ref(false)
|
|||||||
|
|
||||||
const login = computed(() => currentUser.value?.login ?? '')
|
const login = computed(() => currentUser.value?.login ?? '')
|
||||||
|
|
||||||
|
// Avatar: the thumbnail, else the image, else the initials.
|
||||||
|
const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
|
||||||
|
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const avatarBusy = ref(false)
|
||||||
|
const avatarErrors = ref<string[]>([])
|
||||||
|
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
|
||||||
|
const initials = computed(() => profileInitials(currentUser.value))
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
function chooseAvatar(): void {
|
||||||
|
avatarInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAvatarPicked(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file || avatarBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
avatarBusy.value = true
|
||||||
|
avatarErrors.value = []
|
||||||
|
try {
|
||||||
|
// The typed client keeps the 401 refresh-and-replay; FormData lets the
|
||||||
|
// browser set the multipart Content-Type and boundary.
|
||||||
|
const result = await api.POST('/account/avatar', {
|
||||||
|
body: { file_data: '' },
|
||||||
|
bodySerializer: () => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file_data', file, file.name)
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
setCurrentUser(result.data.data)
|
||||||
|
showToast(t('backend::lang.myaccount.avatar_saved'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.response.status === 422) {
|
||||||
|
const details = fieldErrors(result.error?.error.details)
|
||||||
|
avatarErrors.value = details.avatar ?? Object.values(details).flat()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.response.status === 413) {
|
||||||
|
avatarErrors.value = [t('backend::lang.myaccount.avatar_hint')]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
} catch {
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
} finally {
|
||||||
|
avatarBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAvatarRemove(): Promise<void> {
|
||||||
|
if (avatarBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const confirmed = await confirm.ask({
|
||||||
|
message: t('backend::lang.myaccount.avatar_remove_confirm'),
|
||||||
|
confirmLabel: t('backend::lang.myaccount.avatar_remove'),
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
avatarBusy.value = true
|
||||||
|
avatarErrors.value = []
|
||||||
|
try {
|
||||||
|
const result = await api.DELETE('/account/avatar')
|
||||||
|
if (result.data) {
|
||||||
|
setCurrentUser(result.data.data)
|
||||||
|
showToast(t('backend::lang.myaccount.avatar_removed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
} catch {
|
||||||
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
|
} finally {
|
||||||
|
avatarBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function inputId(name: string): string {
|
function inputId(name: string): string {
|
||||||
return `${ID_PREFIX}-${name}`
|
return `${ID_PREFIX}-${name}`
|
||||||
}
|
}
|
||||||
@@ -164,6 +252,67 @@ async function onSave(): Promise<void> {
|
|||||||
|
|
||||||
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
|
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card" data-account-avatar>
|
||||||
|
<h2 class="text-[18px] font-bold">{{ t('backend::lang.myaccount.avatar') }}</h2>
|
||||||
|
<div class="flex flex-wrap items-center gap-5">
|
||||||
|
<img
|
||||||
|
v-if="avatarSrc"
|
||||||
|
:src="avatarSrc"
|
||||||
|
alt=""
|
||||||
|
data-account-avatar-image
|
||||||
|
class="size-20 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
aria-hidden="true"
|
||||||
|
data-account-avatar-initials
|
||||||
|
class="flex size-20 shrink-0 items-center justify-center rounded-full bg-accent text-[26px] font-bold text-on-accent"
|
||||||
|
>{{ initials }}</span
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap gap-2.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="ImageUp"
|
||||||
|
data-action="avatar-upload"
|
||||||
|
:disabled="avatarBusy"
|
||||||
|
@click="chooseAvatar"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.myaccount.avatar_upload') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="currentUser?.avatar"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
:icon="Trash2"
|
||||||
|
data-action="avatar-remove"
|
||||||
|
:disabled="avatarBusy"
|
||||||
|
@click="onAvatarRemove"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.myaccount.avatar_remove') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.avatar_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="avatarInput"
|
||||||
|
type="file"
|
||||||
|
name="file_data"
|
||||||
|
:accept="AVATAR_ACCEPT"
|
||||||
|
class="hidden"
|
||||||
|
data-account-avatar-input
|
||||||
|
@change="onAvatarPicked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="avatarErrors.length > 0" role="alert" data-avatar-error class="flex flex-col gap-0.5 text-[13px] text-danger">
|
||||||
|
<p v-for="(text, index) in avatarErrors" :key="index" class="flex items-center gap-1.5">
|
||||||
|
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||||
|
<span>{{ text }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
|
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
|
||||||
novalidate
|
novalidate
|
||||||
@@ -259,6 +408,16 @@ async function onSave(): Promise<void> {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirm.request.value !== null"
|
||||||
|
:message="confirm.request.value?.message ?? ''"
|
||||||
|
:confirm-label="confirm.request.value?.confirmLabel"
|
||||||
|
:danger="confirm.request.value?.danger"
|
||||||
|
:busy="confirm.busy.value"
|
||||||
|
@confirm="confirm.confirm"
|
||||||
|
@cancel="confirm.cancel"
|
||||||
|
/>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad
|
|||||||
|
|
||||||
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
|
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
|
||||||
|
|
||||||
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
|
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
|
||||||
|
|
||||||
## Types from OpenAPI
|
## Types from OpenAPI
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ Every signed-in administrator can edit their own profile on the My account scree
|
|||||||
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
|
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
|
||||||
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
|
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
|
||||||
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
|
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
|
||||||
|
- **Avatar:** `POST <prefix>/api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE <prefix>/api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials.
|
||||||
|
|
||||||
|
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
|
||||||
|
|
||||||
## Managing administrators
|
## Managing administrators
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-COkYalVD.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-COkYalVD.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-D3quaPLf.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-D3quaPLf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
|||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-1UJLNPmg.js"></script>
|
<script type="module" crossorigin src="./assets/index-D3quaPLf.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-go9AISaH.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
|
||||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||||
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie.
|
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
|
||||||
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
|
||||||
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
|
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
|
||||||
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
|
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
|
||||||
@@ -46,8 +46,9 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
|||||||
|-----------------|---------|
|
|-----------------|---------|
|
||||||
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
|
||||||
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
|
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
|
||||||
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle`). |
|
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). |
|
||||||
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
|
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
|
||||||
|
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
|
||||||
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
|
||||||
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
|
||||||
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
|
||||||
@@ -217,7 +218,12 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
|
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
|
||||||
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
|
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
|
||||||
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
|
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
|
||||||
| `cabana.AdminAccountUpdate` | Swag annotation of the My account route. |
|
| `cabana.AdminAccountUpdate` / `cabana.AdminAccountAvatarUpload` / `cabana.AdminAccountAvatarRemove` | Swag annotations of the My account routes. |
|
||||||
|
| `cabana.AdminAvatar` | The `avatar` of a `cabana.AdminProfile`: the public `url` and the 250x250 crop `thumb_url` (left out when the thumbnail cannot be made). |
|
||||||
|
| `cabana.AvatarMaxBytes` | Largest avatar image the upload route accepts, 2 MiB; `http.body_limits.upload_bytes` still caps the request. |
|
||||||
|
| `cabana.BackendUserAvatar` | Returns a backend user's avatar `attach.File` (the newest avatar row), or nil when there is none; `File.URL` and `File.Thumb` give its public URLs. |
|
||||||
|
| `cabana.BackendUserMorphType` / `cabana.BackendUserAvatarField` | The avatar's `system_files.attachment_type` (WinterCMS's `Backend\Models\User`) and `field` (`avatar`). |
|
||||||
|
| `cabana.BackendUser.MorphName` / `cabana.BackendUser.AttachRelations` | `attach.Owner` and `attach.HasRelations` of the backend user: one public attachOne `avatar`. |
|
||||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -13,8 +14,10 @@ import (
|
|||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -23,6 +26,24 @@ import (
|
|||||||
// the key WinterCMS's Journal author component reads.
|
// the key WinterCMS's Journal author component reads.
|
||||||
const metadataSubtitle = "subtitle"
|
const metadataSubtitle = "subtitle"
|
||||||
|
|
||||||
|
// AvatarMaxBytes is the largest avatar image POST /account/avatar accepts:
|
||||||
|
// 2 MiB. http.body_limits.upload_bytes still caps the whole request.
|
||||||
|
const AvatarMaxBytes int64 = 2 << 20
|
||||||
|
|
||||||
|
// avatarThumbEdge is the edge of the avatar's crop thumbnail, WinterCMS's
|
||||||
|
// myaccount avatar size.
|
||||||
|
const avatarThumbEdge = 250
|
||||||
|
|
||||||
|
// avatarLimits are the attach.Store rules of an avatar: a raster image (jpg,
|
||||||
|
// jpeg, png, gif or webp, a sniffed image MIME type and the decode guard) of
|
||||||
|
// at most AvatarMaxBytes.
|
||||||
|
var avatarLimits = attach.Limits{
|
||||||
|
MaxBytes: AvatarMaxBytes,
|
||||||
|
Extensions: attach.DefaultImageExtensions,
|
||||||
|
MIMETypes: attach.AllowedImageMIMEs,
|
||||||
|
Image: true,
|
||||||
|
}
|
||||||
|
|
||||||
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
|
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
|
||||||
const passwordMaxBytes = 72
|
const passwordMaxBytes = 72
|
||||||
|
|
||||||
@@ -120,8 +141,10 @@ func setMetadataString(raw *string, key, value string) (*string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
|
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
|
||||||
// the My account routes.
|
// the My account routes. The avatar is looked up through db; a lookup or
|
||||||
func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) AdminProfile {
|
// thumbnail failure is logged and leaves the avatar (or its thumb_url) out,
|
||||||
|
// so the profile never fails on it.
|
||||||
|
func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile {
|
||||||
profile := AdminProfile{
|
profile := AdminProfile{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Login: user.Login,
|
Login: user.Login,
|
||||||
@@ -134,9 +157,60 @@ func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) Ad
|
|||||||
if user.Role.ID != 0 {
|
if user.Role.ID != 0 {
|
||||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||||
}
|
}
|
||||||
|
if db != nil {
|
||||||
|
profile.Avatar = s.avatarOf(ctx, db, user.ID)
|
||||||
|
}
|
||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarOf is the public URL and the 250x250 crop thumbnail URL of an
|
||||||
|
// admin's avatar, or nil when there is none. A protected row (is_public
|
||||||
|
// false, possible only in copied data) has no public URL and is left out.
|
||||||
|
func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *AdminAvatar {
|
||||||
|
f, err := BackendUserAvatar(ctx, db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if f == nil || !f.Public() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &AdminAvatar{URL: f.URL()}
|
||||||
|
if bucket := s.bucket(); bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
|
||||||
|
thumb, err := f.Thumb(ctx, bucket, avatarThumbEdge, avatarThumbEdge, "crop")
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
out.ThumbURL = thumb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendUserAvatar returns a backend user's avatar: the newest system_files
|
||||||
|
// row with attachment_type cabana.BackendUserMorphType, field
|
||||||
|
// cabana.BackendUserAvatarField and the user's id, or nil, nil when there is
|
||||||
|
// none. Plugins call it to show an author's picture (File.URL, File.Thumb).
|
||||||
|
func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("cabana: avatar lookup without a database")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
var files []attach.File
|
||||||
|
err := db.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||||
|
Where("attachment_type = ? AND field = ? AND attachment_id = ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID)).
|
||||||
|
Order("id DESC").Limit(1).Find(&files).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &files[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// accountPrincipal is the signed-in backend principal, or nil after a 401
|
// accountPrincipal is the signed-in backend principal, or nil after a 401
|
||||||
// was written.
|
// was written.
|
||||||
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
|
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
|
||||||
@@ -430,3 +504,208 @@ func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err
|
|||||||
}
|
}
|
||||||
writeCRUDError(w, err)
|
writeCRUDError(w, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarCap is the request body cap of an avatar upload: AvatarMaxBytes plus
|
||||||
|
// the multipart framing, lowered to http.body_limits.upload_bytes.
|
||||||
|
func (s *service) avatarCap() int64 {
|
||||||
|
limit := AvatarMaxBytes + multipartOverhead
|
||||||
|
if s != nil && s.uploadBytes > 0 && s.uploadBytes < limit {
|
||||||
|
limit = s.uploadBytes
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// detachAvatars deletes every avatar row of an admin inside tx and returns
|
||||||
|
// the blob keys of the originals and their thumbnails. The rows are locked
|
||||||
|
// first; the caller deletes the blobs after the commit.
|
||||||
|
func detachAvatars(ctx context.Context, tx *gorm.DB, userID uint, keep uint) ([]string, error) {
|
||||||
|
var rows []attach.File
|
||||||
|
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("attachment_type = ? AND field = ? AND attachment_id = ? AND id <> ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID), keep).
|
||||||
|
Order("id").Find(&rows).Error
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(rows))
|
||||||
|
var keys []string
|
||||||
|
for _, f := range rows {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
keys = append(keys, attach.BlobKeys(f)...)
|
||||||
|
}
|
||||||
|
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id IN ?", ids).Delete(&attach.File{}).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAvatarBlobs removes detached avatar blobs once tx has committed; a
|
||||||
|
// failure is logged and never fails the request.
|
||||||
|
func deleteAvatarBlobs(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, userID uint, keys []string) {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
if bucket == nil {
|
||||||
|
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil {
|
||||||
|
slog.Default().WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarUpload serves POST /account/avatar: one multipart file_data part,
|
||||||
|
// stored through attach.Store as a public image of at most AvatarMaxBytes
|
||||||
|
// under a server-generated disk name, attached to the signed-in admin as the
|
||||||
|
// avatar attachOne. The previous avatar row is deleted in the same
|
||||||
|
// transaction and its blobs after the commit.
|
||||||
|
func (s *service) avatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
if bucket == nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket")
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.avatarCap())}
|
||||||
|
r.Body = io.NopCloser(body)
|
||||||
|
mr, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, invalidBody())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
s.writeAvatarError(w, r, body, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
|
||||||
|
writeCRUDError(w, invalidBody())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var stored *attach.File
|
||||||
|
var saved BackendUser
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// The row lock serialises concurrent avatar changes of one admin.
|
||||||
|
user, err := lockAccount(ctx, tx, principal.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Store keeps only a sanitized base name as file_name; the disk name
|
||||||
|
// and the blob key are generated, never taken from the client.
|
||||||
|
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, avatarLimits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stored = f
|
||||||
|
// Exactly one part: anything after file_data is refused.
|
||||||
|
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return invalidBody()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||||
|
if err := q.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
|
||||||
|
"attachment_id": uitoa(user.ID),
|
||||||
|
"attachment_type": BackendUserMorphType,
|
||||||
|
"field": BackendUserAvatarField,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := detachAvatars(ctx, tx, user.ID, f.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
|
||||||
|
saved = user
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// attach.Store wrote the blob before the row; a rolled-back
|
||||||
|
// transaction leaves it to this caller.
|
||||||
|
if stored != nil {
|
||||||
|
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||||
|
}
|
||||||
|
s.writeAvatarError(w, r, body, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarRemove serves DELETE /account/avatar: it deletes the signed-in
|
||||||
|
// admin's avatar rows now and their blobs after the commit. Removing an
|
||||||
|
// avatar that is not there also answers 200.
|
||||||
|
func (s *service) avatarRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal := accountPrincipal(w, r)
|
||||||
|
if principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
slog.Default().ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket := s.bucket()
|
||||||
|
var saved BackendUser
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
user, err := lockAccount(ctx, tx, principal.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keys, err := detachAvatars(ctx, tx, user.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
|
||||||
|
saved = user
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeAccountError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAvatarError maps an avatar upload failure as writeFileError does,
|
||||||
|
// with the field name avatar: a body past the cap is 413, an attach.Store
|
||||||
|
// refusal a 422 on avatar and a malformed multipart body a 422 on body.
|
||||||
|
func (s *service) writeAvatarError(w http.ResponseWriter, r *http.Request, body *bodyReader, err error) {
|
||||||
|
var tooBig *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
|
||||||
|
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, tr := r.Context(), s.translator()
|
||||||
|
var detail string
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, attach.ErrTooLarge):
|
||||||
|
detail = fieldMessage(ctx, tr, "max.file", BackendUserAvatarField, map[string]string{"max": strconv.FormatInt(AvatarMaxBytes/1024, 10)})
|
||||||
|
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
|
||||||
|
detail = fieldMessage(ctx, tr, "mimes", BackendUserAvatarField, map[string]string{"values": strings.Join(attach.DefaultImageExtensions, ", ")})
|
||||||
|
case errors.Is(err, attach.ErrNotImage):
|
||||||
|
detail = fieldMessage(ctx, tr, "image", BackendUserAvatarField, nil)
|
||||||
|
}
|
||||||
|
if detail != "" {
|
||||||
|
writeCRUDError(w, &ValidationError{Details: fieldDetail(BackendUserAvatarField, detail)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body != nil && body.err != nil {
|
||||||
|
writeCRUDError(w, invalidBody())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeAccountError(w, r, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package cabana_test
|
package cabana_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/blob/memblob"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// accountStamp is a per-run suffix for logins and emails, so the shared
|
// accountStamp is a per-run suffix for logins and emails, so the shared
|
||||||
@@ -91,3 +105,90 @@ func TestAccountProfileSmoke(t *testing.T) {
|
|||||||
t.Fatalf("is_superuser=%v err=%v", superuser, err)
|
t.Fatalf("is_superuser=%v err=%v", superuser, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarHandler assembles the admin router like adminHandler, with a memblob
|
||||||
|
// attachment bucket published and an 8 MiB upload cap. withBucket false
|
||||||
|
// leaves the bucket out.
|
||||||
|
func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": 8 << 20} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var bucket *blob.Bucket
|
||||||
|
if withBucket {
|
||||||
|
bucket = memblob.OpenBucket(nil)
|
||||||
|
t.Cleanup(func() { _ = bucket.Close() })
|
||||||
|
if err := attach.Publish(app, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h, bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAvatar posts one multipart part (name field, file name name) to
|
||||||
|
// POST /account/avatar with a Bearer token.
|
||||||
|
func uploadAvatar(t *testing.T, h http.Handler, token, field, name string, data []byte) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreateFormFile(field, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, adminAPI("/account/avatar"), &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountAvatarSmoke(t *testing.T) {
|
||||||
|
gdb := adminGorm(t)
|
||||||
|
h, _ := avatarHandler(t, gdb, true)
|
||||||
|
login := "acct-avatar-" + accountStamp()
|
||||||
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||||
|
token := signIn(t, h, login)
|
||||||
|
|
||||||
|
up := uploadAvatar(t, h, token, "file_data", "me.png", conformPNG(t))
|
||||||
|
if up.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
|
||||||
|
}
|
||||||
|
me := getAuth(t, h, adminAPI("/auth/me"), token)
|
||||||
|
got := profileOf(t, me.Body.Bytes())
|
||||||
|
if got.Avatar == nil || got.Avatar.URL == "" || got.Avatar.ThumbURL == "" {
|
||||||
|
t.Fatalf("me avatar = %+v body=%s", got.Avatar, me.Body.String())
|
||||||
|
}
|
||||||
|
del := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil)
|
||||||
|
if del.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete status=%d body=%s", del.Code, del.Body.String())
|
||||||
|
}
|
||||||
|
me = getAuth(t, h, adminAPI("/auth/me"), token)
|
||||||
|
if got := profileOf(t, me.Body.Bytes()); got.Avatar != nil {
|
||||||
|
t.Fatalf("avatar after delete = %+v", got.Avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ type AdminProfile struct {
|
|||||||
Subtitle string `json:"subtitle"`
|
Subtitle string `json:"subtitle"`
|
||||||
IsSuperuser bool `json:"is_superuser"`
|
IsSuperuser bool `json:"is_superuser"`
|
||||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||||
|
// Avatar is present when the admin has a public avatar.
|
||||||
|
Avatar *AdminAvatar `json:"avatar,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAvatar is an admin's avatar: the public URL of the image and of its
|
||||||
|
// 250x250 crop thumbnail (left out when the thumbnail cannot be made).
|
||||||
|
type AdminAvatar struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
ThumbURL string `json:"thumb_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminAccountRequest is the body of PUT /account. It documents the only keys
|
// AdminAccountRequest is the body of PUT /account. It documents the only keys
|
||||||
@@ -181,7 +190,7 @@ func AdminLogout() {}
|
|||||||
// AdminMe documents GET /auth/me.
|
// AdminMe documents GET /auth/me.
|
||||||
//
|
//
|
||||||
// @Summary Current admin
|
// @Summary Current admin
|
||||||
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role.
|
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
|
||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
@@ -209,6 +218,36 @@ func AdminMe() {}
|
|||||||
// @Router /account [put]
|
// @Router /account [put]
|
||||||
func AdminAccountUpdate() {}
|
func AdminAccountUpdate() {}
|
||||||
|
|
||||||
|
// AdminAccountAvatarUpload documents POST /account/avatar.
|
||||||
|
//
|
||||||
|
// @Summary Upload my avatar
|
||||||
|
// @Description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param file_data formData file true "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)"
|
||||||
|
// @Success 200 {object} Envelope[AdminProfile]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 413 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Router /account/avatar [post]
|
||||||
|
func AdminAccountAvatarUpload() {}
|
||||||
|
|
||||||
|
// AdminAccountAvatarRemove documents DELETE /account/avatar.
|
||||||
|
//
|
||||||
|
// @Summary Remove my avatar
|
||||||
|
// @Description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Success 200 {object} Envelope[AdminProfile]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Router /account/avatar [delete]
|
||||||
|
func AdminAccountAvatarRemove() {}
|
||||||
|
|
||||||
// AdminNavigation documents GET /navigation.
|
// AdminNavigation documents GET /navigation.
|
||||||
//
|
//
|
||||||
// @Summary Admin navigation
|
// @Summary Admin navigation
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -59,6 +60,25 @@ type BackendUser struct {
|
|||||||
|
|
||||||
func (BackendUser) TableName() string { return "backend_users" }
|
func (BackendUser) TableName() string { return "backend_users" }
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BackendUserMorphType is the system_files attachment_type of a backend
|
||||||
|
// user's files: WinterCMS's class string, so avatar rows copied from
|
||||||
|
// WinterCMS match as they are.
|
||||||
|
BackendUserMorphType = "Backend\\Models\\User"
|
||||||
|
// BackendUserAvatarField is the attachOne relation (system_files.field)
|
||||||
|
// of a backend user's avatar.
|
||||||
|
BackendUserAvatarField = "avatar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MorphName implements attach.Owner with WinterCMS's class string.
|
||||||
|
func (BackendUser) MorphName() string { return BackendUserMorphType }
|
||||||
|
|
||||||
|
// AttachRelations implements attach.HasRelations: one public attachOne
|
||||||
|
// avatar, as WinterCMS's Backend\Models\User declares it.
|
||||||
|
func (BackendUser) AttachRelations() []attach.Relation {
|
||||||
|
return []attach.Relation{{Name: BackendUserAvatarField, Public: true}}
|
||||||
|
}
|
||||||
|
|
||||||
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
|
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
|
||||||
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
|
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
|
||||||
// all read as an empty subtitle.
|
// all read as an empty subtitle.
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ func (s *service) mount(r pact.Router) {
|
|||||||
g.Get("/auth/me", s.me)
|
g.Get("/auth/me", s.me)
|
||||||
// My account: the signed-in admin edits their own profile.
|
// My account: the signed-in admin edits their own profile.
|
||||||
g.Put("/account", requireAjax(s.accountUpdate))
|
g.Put("/account", requireAjax(s.accountUpdate))
|
||||||
|
g.Post("/account/avatar", requireAjax(s.avatarUpload))
|
||||||
|
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
|
||||||
g.Get("/navigation", s.navigation)
|
g.Get("/navigation", s.navigation)
|
||||||
// Form previews render through RenderMarkdown; nothing is stored.
|
// Form previews render through RenderMarkdown; nothing is stored.
|
||||||
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
|
||||||
|
|||||||
@@ -366,6 +366,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return rec
|
return rec
|
||||||
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||||
|
{"POST /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreateFormFile("file_data", "avatar.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(conformPNG(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := e.sendWith(t, http.MethodPost, "/account/avatar", buf.Bytes(), mw.FormDataContentType(), nil)
|
||||||
|
var body cabana.Envelope[cabana.AdminProfile]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar == nil || body.Data.Avatar.URL == "" {
|
||||||
|
t.Fatalf("avatar upload body = %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||||
|
{"DELETE /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
rec := e.sendWith(t, http.MethodDelete, "/account/avatar", nil, "", nil)
|
||||||
|
var body cabana.Envelope[cabana.AdminProfile]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar != nil {
|
||||||
|
t.Fatalf("avatar remove body = %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
|
||||||
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
|
||||||
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
|
||||||
|
|||||||
@@ -88,9 +88,11 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sort.Strings(unsafe)
|
sort.Strings(unsafe)
|
||||||
want := []string{
|
want := []string{
|
||||||
|
"DELETE /account/avatar",
|
||||||
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
||||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
|
||||||
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||||
|
"POST /account/avatar",
|
||||||
"POST /auth/login",
|
"POST /auth/login",
|
||||||
"POST /auth/logout",
|
"POST /auth/logout",
|
||||||
"POST /auth/refresh",
|
"POST /auth/refresh",
|
||||||
@@ -118,7 +120,7 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||||
}
|
}
|
||||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 27 besides login):\n%s", strings.Join(unsafe, "\n"))
|
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 29 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||||
}
|
}
|
||||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// nested pattern serving field options, filter options and relation lists.
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// refresh, logout, account update, markdown preview, settings put, create, bulk-delete, bulk action,
|
// refresh, logout, account update, avatar upload and remove, markdown preview, settings put, create, bulk-delete, bulk action,
|
||||||
// widget action, toolbar action, update, delete, record action, link,
|
// widget action, toolbar action, update, delete, record action, link,
|
||||||
// unlink, file
|
// unlink, file
|
||||||
// upload, file reorder, file caption, file remove, relation child
|
// upload, file reorder, file caption, file remove, relation child
|
||||||
// create, update and delete, pivot update, child file upload, reorder,
|
// create, update and delete, pivot update, child file upload, reorder,
|
||||||
// caption and remove
|
// caption and remove
|
||||||
if unsafe != 27 {
|
if unsafe != 29 {
|
||||||
t.Fatalf("walked %d state-changing routes, want 27: %v", unsafe, router.order)
|
t.Fatalf("walked %d state-changing routes, want 29: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
loginHandler := router.handlers[login]
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "POST /auth/logout", public: true},
|
{key: "POST /auth/logout", public: true},
|
||||||
{key: "GET /auth/me"},
|
{key: "GET /auth/me"},
|
||||||
{key: "PUT /account"},
|
{key: "PUT /account"},
|
||||||
|
{key: "POST /account/avatar"},
|
||||||
|
{key: "DELETE /account/avatar"},
|
||||||
{key: "GET /navigation"},
|
{key: "GET /navigation"},
|
||||||
{key: "GET /settings"},
|
{key: "GET /settings"},
|
||||||
{key: "GET /settings/{code}/schema"},
|
{key: "GET /settings/{code}/schema"},
|
||||||
|
|||||||
@@ -248,3 +248,10 @@ myaccount:
|
|||||||
field_not_editable: This field cannot be changed on My account.
|
field_not_editable: This field cannot be changed on My account.
|
||||||
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
|
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
|
||||||
saved: Account saved
|
saved: Account saved
|
||||||
|
avatar: Avatar
|
||||||
|
avatar_upload: Upload picture
|
||||||
|
avatar_remove: Remove picture
|
||||||
|
avatar_remove_confirm: Remove your avatar?
|
||||||
|
avatar_hint: JPG, PNG, GIF or WebP, up to 2 MB.
|
||||||
|
avatar_saved: Avatar saved
|
||||||
|
avatar_removed: Avatar removed
|
||||||
|
|||||||
@@ -270,3 +270,10 @@ myaccount:
|
|||||||
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
|
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
|
||||||
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
|
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
|
||||||
saved: Zapisano konto
|
saved: Zapisano konto
|
||||||
|
avatar: Awatar
|
||||||
|
avatar_upload: Wgraj zdjęcie
|
||||||
|
avatar_remove: Usuń zdjęcie
|
||||||
|
avatar_remove_confirm: Usunąć awatar?
|
||||||
|
avatar_hint: JPG, PNG, GIF lub WebP, maksymalnie 2 MB.
|
||||||
|
avatar_saved: Zapisano awatar
|
||||||
|
avatar_removed: Usunięto awatar
|
||||||
|
|||||||
Reference in New Issue
Block a user