core: add UserSelfSerializer and separate method for users to update themselves with limited fields

rework user settings page to better use form
closes #1227

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-08-05 17:38:48 +02:00
parent 90775d5122
commit 6fe5175f21
9 changed files with 299 additions and 146 deletions

2
.gitignore vendored
View file

@ -200,4 +200,4 @@ media/
*mmdb
.idea/
api/
/api/

View file

@ -10,6 +10,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.utils import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
@ -59,12 +60,40 @@ class UserSerializer(ModelSerializer):
]
class UserSelfSerializer(ModelSerializer):
"""User Serializer for information a user can retrieve about themselves and
update about themselves"""
is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True)
groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
uid = CharField(read_only=True)
class Meta:
model = User
fields = [
"pk",
"username",
"name",
"is_active",
"is_superuser",
"groups",
"email",
"avatar",
"uid",
]
extra_kwargs = {
"is_active": {"read_only": True},
}
class SessionUserSerializer(PassiveSerializer):
"""Response for the /user/me endpoint, returns the currently active user (as `user` property)
and, if this user is being impersonated, the original user in the `original` property."""
user = UserSerializer()
original = UserSerializer(required=False)
user = UserSelfSerializer()
original = UserSelfSerializer(required=False)
class UserMetricsSerializer(PassiveSerializer):
@ -147,14 +176,34 @@ class UserViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=invalid-name
def me(self, request: Request) -> Response:
"""Get information about current user"""
serializer = SessionUserSerializer(data={"user": UserSerializer(request.user).data})
serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data})
if SESSION_IMPERSONATE_USER in request._request.session:
serializer.initial_data["original"] = UserSerializer(
serializer.initial_data["original"] = UserSelfSerializer(
request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
).data
serializer.is_valid()
return Response(serializer.data)
@extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
@action(
methods=["PUT"],
detail=False,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
)
def update_self(self, request: Request) -> Response:
"""Allow users to change information on their own profile"""
data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data)
if not data.is_valid():
return Response(data.errors)
new_user = data.save()
# If we're impersonating, we need to update that user object
# since it caches the full object
if SESSION_IMPERSONATE_USER in request.session:
request.session[SESSION_IMPERSONATE_USER] = new_user
return self.me(request)
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])

View file

@ -3185,6 +3185,38 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
/api/v2beta/core/users/update_self/:
put:
operationId: core_users_update_self_update
description: Allow users to change information on their own profile
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserSelfRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/UserSelfRequest'
multipart/form-data:
schema:
$ref: '#/components/schemas/UserSelfRequest'
required: true
security:
- authentik: []
- cookieAuth: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SessionUser'
description: ''
'400':
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
/api/v2beta/crypto/certificatekeypairs/:
get:
operationId: crypto_certificatekeypairs_list
@ -27577,9 +27609,9 @@ components:
and, if this user is being impersonated, the original user in the `original` property.
properties:
user:
$ref: '#/components/schemas/User'
$ref: '#/components/schemas/UserSelf'
original:
$ref: '#/components/schemas/User'
$ref: '#/components/schemas/UserSelf'
required:
- user
SetIconRequest:
@ -28478,6 +28510,82 @@ components:
required:
- name
- username
UserSelf:
type: object
description: |-
User Serializer for information a user can retrieve about themselves and
update about themselves
properties:
pk:
type: integer
readOnly: true
title: ID
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
name:
type: string
description: User's display name.
is_active:
type: boolean
readOnly: true
title: Active
description: Designates whether this user should be treated as active. Unselect
this instead of deleting accounts.
is_superuser:
type: boolean
readOnly: true
groups:
type: array
items:
$ref: '#/components/schemas/Group'
readOnly: true
email:
type: string
format: email
title: Email address
maxLength: 254
avatar:
type: string
readOnly: true
uid:
type: string
readOnly: true
required:
- avatar
- groups
- is_active
- is_superuser
- name
- pk
- uid
- username
UserSelfRequest:
type: object
description: |-
User Serializer for information a user can retrieve about themselves and
update about themselves
properties:
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
name:
type: string
description: User's display name.
email:
type: string
format: email
title: Email address
maxLength: 254
required:
- name
- username
UserSetting:
type: object
description: Serializer for User settings for stages and sources

View file

@ -3,17 +3,20 @@ import { EVENT_REFRESH } from "../../constants";
import { Form } from "./Form";
export abstract class ModelForm<T, PKT extends string | number> extends Form<T> {
viewportCheck = true;
abstract loadInstance(pk: PKT): Promise<T>;
@property({ attribute: false })
set instancePk(value: PKT) {
this._instancePk = value;
if (this.isInViewport) {
this.loadInstance(value).then((instance) => {
this.instance = instance;
this.requestUpdate();
});
if (this.viewportCheck && !this.isInViewport) {
return;
}
this.loadInstance(value).then((instance) => {
this.instance = instance;
this.requestUpdate();
});
}
private _instancePk?: PKT;

View file

@ -1103,7 +1103,7 @@ msgstr "Delete Consent"
msgid "Delete Session"
msgstr "Delete Session"
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
msgid "Delete account"
msgstr "Delete account"
@ -1307,7 +1307,7 @@ msgstr "Either no applications are defined, or you don't have access to any."
#: src/flows/stages/identification/IdentificationStage.ts
#: src/pages/events/TransportForm.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserViewPage.ts
msgid "Email"
@ -1444,7 +1444,6 @@ msgstr "Everything is ok."
msgid "Exception"
msgstr "Exception"
#: src/pages/flows/FlowListPage.ts
#: src/pages/flows/FlowViewPage.ts
msgid "Execute"
msgstr "Execute"
@ -1497,7 +1496,6 @@ msgstr "Expiry date"
msgid "Explicit Consent"
msgstr "Explicit Consent"
#: src/pages/flows/FlowListPage.ts
#: src/pages/flows/FlowViewPage.ts
msgid "Export"
msgstr "Export"
@ -2132,7 +2130,7 @@ msgstr "Load servers"
#: src/flows/stages/prompt/PromptStage.ts
#: src/pages/applications/ApplicationViewPage.ts
#: src/pages/applications/ApplicationViewPage.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/utils.ts
msgid "Loading"
msgstr "Loading"
@ -2421,7 +2419,7 @@ msgstr "My Applications"
#: src/pages/stages/user_login/UserLoginStageForm.ts
#: src/pages/stages/user_logout/UserLogoutStageForm.ts
#: src/pages/stages/user_write/UserWriteStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts
@ -3146,7 +3144,7 @@ msgstr "Request token URL"
msgid "Required"
msgstr "Required"
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
@ -3802,7 +3800,7 @@ msgstr "Successfully updated binding."
msgid "Successfully updated certificate-key pair."
msgstr "Successfully updated certificate-key pair."
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
msgid "Successfully updated details."
msgstr "Successfully updated details."
@ -4339,7 +4337,7 @@ msgstr "Up-to-date!"
#: src/pages/stages/StageListPage.ts
#: src/pages/stages/prompt/PromptListPage.ts
#: src/pages/tenants/TenantListPage.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
@ -4443,7 +4441,7 @@ msgstr "Update User"
msgid "Update available"
msgstr "Update available"
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSettingsPage.ts
msgid "Update details"
msgstr "Update details"
@ -4576,7 +4574,7 @@ msgstr "User {0}"
msgid "User's avatar"
msgstr "User's avatar"
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
msgid "User's display name."
msgstr "User's display name."
@ -4596,7 +4594,7 @@ msgstr "Userinfo URL"
#: src/flows/stages/identification/IdentificationStage.ts
#: src/pages/policies/reputation/UserReputationListPage.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserViewPage.ts
msgid "Username"

View file

@ -1097,7 +1097,7 @@ msgstr ""
msgid "Delete Session"
msgstr ""
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
msgid "Delete account"
msgstr ""
@ -1299,7 +1299,7 @@ msgstr ""
#: src/flows/stages/identification/IdentificationStage.ts
#: src/pages/events/TransportForm.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserViewPage.ts
msgid "Email"
@ -1436,7 +1436,6 @@ msgstr ""
msgid "Exception"
msgstr ""
#: src/pages/flows/FlowListPage.ts
#: src/pages/flows/FlowViewPage.ts
msgid "Execute"
msgstr ""
@ -1489,7 +1488,6 @@ msgstr ""
msgid "Explicit Consent"
msgstr ""
#: src/pages/flows/FlowListPage.ts
#: src/pages/flows/FlowViewPage.ts
msgid "Export"
msgstr ""
@ -2124,7 +2122,7 @@ msgstr ""
#: src/flows/stages/prompt/PromptStage.ts
#: src/pages/applications/ApplicationViewPage.ts
#: src/pages/applications/ApplicationViewPage.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/utils.ts
msgid "Loading"
msgstr ""
@ -2413,7 +2411,7 @@ msgstr ""
#: src/pages/stages/user_login/UserLoginStageForm.ts
#: src/pages/stages/user_logout/UserLogoutStageForm.ts
#: src/pages/stages/user_write/UserWriteStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserListPage.ts
#: src/pages/users/UserViewPage.ts
@ -3138,7 +3136,7 @@ msgstr ""
msgid "Required"
msgstr ""
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr ""
@ -3794,7 +3792,7 @@ msgstr ""
msgid "Successfully updated certificate-key pair."
msgstr ""
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
msgid "Successfully updated details."
msgstr ""
@ -4324,7 +4322,7 @@ msgstr ""
#: src/pages/stages/StageListPage.ts
#: src/pages/stages/prompt/PromptListPage.ts
#: src/pages/tenants/TenantListPage.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
#: src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts
@ -4428,7 +4426,7 @@ msgstr ""
msgid "Update available"
msgstr ""
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSettingsPage.ts
msgid "Update details"
msgstr ""
@ -4561,7 +4559,7 @@ msgstr ""
msgid "User's avatar"
msgstr ""
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
msgid "User's display name."
msgstr ""
@ -4581,7 +4579,7 @@ msgstr ""
#: src/flows/stages/identification/IdentificationStage.ts
#: src/pages/policies/reputation/UserReputationListPage.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/user-settings/UserDetailsPage.ts
#: src/pages/user-settings/UserSelfForm.ts
#: src/pages/users/UserForm.ts
#: src/pages/users/UserViewPage.ts
msgid "Username"

View file

@ -1,108 +0,0 @@
import { t } from "@lingui/macro";
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import AKGlobal from "../../authentik.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import { CoreApi, User } from "authentik-api";
import { me } from "../../api/Users";
import { ifDefined } from "lit-html/directives/if-defined";
import { DEFAULT_CONFIG, tenant } from "../../api/Config";
import "../../elements/forms/FormElement";
import "../../elements/EmptyState";
import "../../elements/forms/Form";
import "../../elements/forms/HorizontalFormElement";
import { until } from "lit-html/directives/until";
@customElement("ak-user-details")
export class UserDetailsPage extends LitElement {
static get styles(): CSSResult[] {
return [PFBase, PFCard, PFForm, PFFormControl, PFButton, AKGlobal];
}
@property({ attribute: false })
user?: User;
firstUpdated(): void {
me().then((user) => {
this.user = user.user;
});
}
render(): TemplateResult {
if (!this.user) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`<div class="pf-c-card">
<div class="pf-c-card__title">${t`Update details`}</div>
<div class="pf-c-card__body">
<ak-form
successMessage=${t`Successfully updated details.`}
.send=${(data: unknown) => {
return new CoreApi(DEFAULT_CONFIG).coreUsersUpdate({
id: this.user?.pk || 0,
userRequest: data as User,
});
}}
>
<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${t`Username`}
?required=${true}
name="username"
>
<input
type="text"
value="${ifDefined(this.user?.username)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.user?.name)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${t`User's display name.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Email`} name="email">
<input
type="email"
value="${ifDefined(this.user?.email)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<button class="pf-c-button pf-m-primary">${t`Update`}</button>
${until(
tenant().then((tenant) => {
if (tenant.flowUnenrollment) {
return html`<a
class="pf-c-button pf-m-danger"
href="/if/flow/${tenant.flowUnenrollment}"
>
${t`Delete account`}
</a>`;
}
return html``;
}),
)}
</div>
</div>
</div>
</form>
</ak-form>
</div>
</div>`;
}
}

View file

@ -0,0 +1,100 @@
import { t } from "@lingui/macro";
import { customElement, html, TemplateResult } from "lit-element";
import { CoreApi, UserSelf } from "authentik-api";
import { ifDefined } from "lit-html/directives/if-defined";
import { DEFAULT_CONFIG, tenant } from "../../api/Config";
import "../../elements/forms/FormElement";
import "../../elements/EmptyState";
import "../../elements/forms/Form";
import "../../elements/forms/HorizontalFormElement";
import { until } from "lit-html/directives/until";
import { ModelForm } from "../../elements/forms/ModelForm";
@customElement("ak-user-self-form")
export class UserSelfForm extends ModelForm<UserSelf, number> {
viewportCheck = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadInstance(pk: number): Promise<UserSelf> {
return new CoreApi(DEFAULT_CONFIG).coreUsersMeRetrieve().then((su) => {
return su.user;
});
}
getSuccessMessage(): string {
return t`Successfully updated details.`;
}
send = (data: UserSelf): Promise<UserSelf> => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersUpdateSelfUpdate({
userSelfRequest: data,
})
.then((su) => {
return su.user;
});
};
renderForm(): TemplateResult {
if (!this.instance) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Username`} ?required=${true} name="username">
<input
type="text"
value="${ifDefined(this.instance?.username)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${t`User's display name.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Email`} name="email">
<input
type="email"
value="${ifDefined(this.instance?.email)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<button
@click=${(ev: Event) => {
return this.submit(ev);
}}
class="pf-c-button pf-m-primary"
>
${t`Update`}
</button>
${until(
tenant().then((tenant) => {
if (tenant.flowUnenrollment) {
return html`<a
class="pf-c-button pf-m-danger"
href="/if/flow/${tenant.flowUnenrollment}"
>
${t`Delete account`}
</a>`;
}
return html``;
}),
)}
</div>
</div>
</div>
</form>`;
}
}

View file

@ -20,7 +20,7 @@ import { ifDefined } from "lit-html/directives/if-defined";
import "../../elements/Tabs";
import "../../elements/PageHeader";
import "./tokens/UserTokenList";
import "./UserDetailsPage";
import "./UserSelfForm";
import "./settings/UserSettingsAuthenticatorDuo";
import "./settings/UserSettingsAuthenticatorStatic";
import "./settings/UserSettingsAuthenticatorTOTP";
@ -132,7 +132,12 @@ export class UserSettingsPage extends LitElement {
data-tab-title="${t`User details`}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<ak-user-details></ak-user-details>
<div class="pf-c-card">
<div class="pf-c-card__title">${t`Update details`}</div>
<div class="pf-c-card__body">
<ak-user-self-form .instancePk=${1}></ak-user-self-form>
</div>
</div>
</section>
<section
slot="page-tokens"