mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-26 18:56:39 +00:00
Allowing toggle of expiration date for refresh token (#20846)
* Allowing removal of expiration date for refresh token * Adjust wording and add icon * Update current * Update src/translations/en.json Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Update wording * Allow enable and disable * Better type * Better handle errors * Use relative date * Update src/panels/profile/ha-refresh-tokens-card.ts * Update API --------- Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
This commit is contained in:
parent
ccebae84a7
commit
7a7a355765
@ -11,6 +11,7 @@ export interface RefreshToken {
|
|||||||
client_id: string;
|
client_id: string;
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
expire_at?: string;
|
||||||
id: string;
|
id: string;
|
||||||
is_current: boolean;
|
is_current: boolean;
|
||||||
last_used_at?: string;
|
last_used_at?: string;
|
||||||
|
@ -1,15 +1,33 @@
|
|||||||
import { mdiDelete } from "@mdi/js";
|
|
||||||
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
|
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
|
||||||
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
|
import type { ActionDetail } from "@material/mwc-list";
|
||||||
|
import {
|
||||||
|
mdiAndroid,
|
||||||
|
mdiApple,
|
||||||
|
mdiClockCheckOutline,
|
||||||
|
mdiClockRemoveOutline,
|
||||||
|
mdiDelete,
|
||||||
|
mdiDotsVertical,
|
||||||
|
mdiWeb,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import {
|
||||||
|
CSSResultGroup,
|
||||||
|
LitElement,
|
||||||
|
TemplateResult,
|
||||||
|
css,
|
||||||
|
html,
|
||||||
|
nothing,
|
||||||
|
} from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import { relativeTime } from "../../common/datetime/relative_time";
|
import { relativeTime } from "../../common/datetime/relative_time";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
|
import "../../components/ha-button-menu";
|
||||||
import "../../components/ha-card";
|
import "../../components/ha-card";
|
||||||
import "../../components/ha-settings-row";
|
|
||||||
import "../../components/ha-icon-button";
|
import "../../components/ha-icon-button";
|
||||||
import { RefreshToken } from "../../data/refresh_token";
|
import "../../components/ha-label";
|
||||||
|
import "../../components/ha-settings-row";
|
||||||
import { deleteAllRefreshTokens } from "../../data/auth";
|
import { deleteAllRefreshTokens } from "../../data/auth";
|
||||||
|
import { RefreshToken } from "../../data/refresh_token";
|
||||||
import {
|
import {
|
||||||
showAlertDialog,
|
showAlertDialog,
|
||||||
showConfirmationDialog,
|
showConfirmationDialog,
|
||||||
@ -17,6 +35,11 @@ import {
|
|||||||
import { haStyle } from "../../resources/styles";
|
import { haStyle } from "../../resources/styles";
|
||||||
import { HomeAssistant } from "../../types";
|
import { HomeAssistant } from "../../types";
|
||||||
|
|
||||||
|
// Client ID used by iOS app
|
||||||
|
const iOSclientId = "https://home-assistant.io/iOS";
|
||||||
|
// Client ID used by Android app
|
||||||
|
const androidClientId = "https://home-assistant.io/android";
|
||||||
|
|
||||||
const compareTokenLastUsedAt = (tokenA: RefreshToken, tokenB: RefreshToken) => {
|
const compareTokenLastUsedAt = (tokenA: RefreshToken, tokenB: RefreshToken) => {
|
||||||
const timeA = tokenA.last_used_at ? new Date(tokenA.last_used_at) : 0;
|
const timeA = tokenA.last_used_at ? new Date(tokenA.last_used_at) : 0;
|
||||||
const timeB = tokenB.last_used_at ? new Date(tokenB.last_used_at) : 0;
|
const timeB = tokenB.last_used_at ? new Date(tokenB.last_used_at) : 0;
|
||||||
@ -38,26 +61,44 @@ class HaRefreshTokens extends LitElement {
|
|||||||
private _refreshTokens = memoizeOne(
|
private _refreshTokens = memoizeOne(
|
||||||
(refreshTokens: RefreshToken[]): RefreshToken[] =>
|
(refreshTokens: RefreshToken[]): RefreshToken[] =>
|
||||||
refreshTokens
|
refreshTokens
|
||||||
?.filter((token) => token.type === "normal")
|
.filter((token) => token.type === "normal")
|
||||||
.sort(compareTokenLastUsedAt)
|
.sort(compareTokenLastUsedAt)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _formatTokenName = (token: RefreshToken): string => {
|
||||||
|
if (token.client_id === iOSclientId) {
|
||||||
|
return this.hass.localize("ui.panel.profile.refresh_tokens.ios_app");
|
||||||
|
}
|
||||||
|
if (token.client_id === androidClientId) {
|
||||||
|
return this.hass.localize("ui.panel.profile.refresh_tokens.android_app");
|
||||||
|
}
|
||||||
|
return token.client_name || token.client_id;
|
||||||
|
};
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
const refreshTokens = this._refreshTokens(this.refreshTokens!);
|
const refreshTokens = this.refreshTokens
|
||||||
return html`<ha-card
|
? this._refreshTokens(this.refreshTokens)
|
||||||
|
: [];
|
||||||
|
return html`
|
||||||
|
<ha-card
|
||||||
.header=${this.hass.localize("ui.panel.profile.refresh_tokens.header")}
|
.header=${this.hass.localize("ui.panel.profile.refresh_tokens.header")}
|
||||||
>
|
>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
${this.hass.localize("ui.panel.profile.refresh_tokens.description")}
|
${this.hass.localize("ui.panel.profile.refresh_tokens.description")}
|
||||||
${refreshTokens?.length
|
${refreshTokens.length
|
||||||
? refreshTokens!.map(
|
? refreshTokens.map(
|
||||||
(token) =>
|
(token) => html`
|
||||||
html`<ha-settings-row three-line>
|
<ha-settings-row three-line>
|
||||||
<span slot="heading"
|
<ha-svg-icon
|
||||||
>${this.hass.localize(
|
slot="prefix"
|
||||||
"ui.panel.profile.refresh_tokens.token_title",
|
.path=${token.client_id === iOSclientId
|
||||||
{ clientId: token.client_id }
|
? mdiApple
|
||||||
)}
|
: token.client_id === androidClientId
|
||||||
|
? mdiAndroid
|
||||||
|
: mdiWeb}
|
||||||
|
></ha-svg-icon>
|
||||||
|
<span slot="heading" class="primary">
|
||||||
|
${this._formatTokenName(token)}
|
||||||
</span>
|
</span>
|
||||||
<div slot="description">
|
<div slot="description">
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
@ -71,7 +112,15 @@ class HaRefreshTokens extends LitElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div slot="description">
|
<div slot="description">
|
||||||
${token.last_used_at
|
${token.is_current
|
||||||
|
? html`
|
||||||
|
<span class="current-session">
|
||||||
|
<span class="dot"></span> ${this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.current_session"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
: token.last_used_at
|
||||||
? this.hass.localize(
|
? this.hass.localize(
|
||||||
"ui.panel.profile.refresh_tokens.last_used",
|
"ui.panel.profile.refresh_tokens.last_used",
|
||||||
{
|
{
|
||||||
@ -86,28 +135,66 @@ class HaRefreshTokens extends LitElement {
|
|||||||
"ui.panel.profile.refresh_tokens.not_used"
|
"ui.panel.profile.refresh_tokens.not_used"
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div slot="description">
|
||||||
${token.is_current
|
${token.expire_at
|
||||||
? html`<simple-tooltip
|
? this.hass.localize(
|
||||||
animation-delay="0"
|
"ui.panel.profile.refresh_tokens.expires_in",
|
||||||
position="left"
|
{
|
||||||
>
|
date: relativeTime(
|
||||||
${this.hass.localize(
|
new Date(token.expire_at),
|
||||||
"ui.panel.profile.refresh_tokens.current_token_tooltip"
|
this.hass.locale
|
||||||
)}
|
),
|
||||||
</simple-tooltip>`
|
}
|
||||||
: ""}
|
|
||||||
<ha-icon-button
|
|
||||||
.token=${token}
|
|
||||||
.disabled=${token.is_current}
|
|
||||||
.label=${this.hass.localize("ui.common.delete")}
|
|
||||||
.path=${mdiDelete}
|
|
||||||
@click=${this._deleteToken}
|
|
||||||
></ha-icon-button>
|
|
||||||
</div>
|
|
||||||
</ha-settings-row>`
|
|
||||||
)
|
)
|
||||||
: ""}
|
: this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.never_expires"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ha-button-menu
|
||||||
|
corner="BOTTOM_END"
|
||||||
|
menuCorner="END"
|
||||||
|
@action=${this._handleAction}
|
||||||
|
.token=${token}
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
slot="trigger"
|
||||||
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
.path=${mdiDotsVertical}
|
||||||
|
></ha-icon-button>
|
||||||
|
<ha-list-item graphic="icon">
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="graphic"
|
||||||
|
.path=${token.expire_at
|
||||||
|
? mdiClockRemoveOutline
|
||||||
|
: mdiClockCheckOutline}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${token.expire_at
|
||||||
|
? this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.disable_token_expiration"
|
||||||
|
)
|
||||||
|
: this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.enable_token_expiration"
|
||||||
|
)}
|
||||||
|
</ha-list-item>
|
||||||
|
<ha-list-item
|
||||||
|
graphic="icon"
|
||||||
|
class="warning"
|
||||||
|
.disabled=${token.is_current}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
class="warning"
|
||||||
|
slot="graphic"
|
||||||
|
.path=${mdiDelete}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize("ui.common.delete")}
|
||||||
|
</ha-list-item>
|
||||||
|
</ha-button-menu>
|
||||||
|
</div>
|
||||||
|
</ha-settings-row>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<mwc-button class="warning" @click=${this._deleteAllTokens}>
|
<mwc-button class="warning" @click=${this._deleteAllTokens}>
|
||||||
@ -116,17 +203,75 @@ class HaRefreshTokens extends LitElement {
|
|||||||
)}
|
)}
|
||||||
</mwc-button>
|
</mwc-button>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>`;
|
</ha-card>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _deleteToken(ev: Event): Promise<void> {
|
private async _handleAction(ev: CustomEvent<ActionDetail>) {
|
||||||
const token = (ev.currentTarget as any).token;
|
const token = (ev.currentTarget as any).token;
|
||||||
|
switch (ev.detail.index) {
|
||||||
|
case 0:
|
||||||
|
this._toggleTokenExpiration(token);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
this._deleteToken(token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _toggleTokenExpiration(token: RefreshToken): Promise<void> {
|
||||||
|
const enable = !token.expire_at;
|
||||||
|
if (!enable) {
|
||||||
if (
|
if (
|
||||||
!(await showConfirmationDialog(this, {
|
!(await showConfirmationDialog(this, {
|
||||||
text: this.hass.localize(
|
title: this.hass.localize(
|
||||||
"ui.panel.profile.refresh_tokens.confirm_delete",
|
"ui.panel.profile.refresh_tokens.confirm_disable_token_expiration_title"
|
||||||
{ name: token.client_name || token.client_id }
|
|
||||||
),
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.confirm_disable_token_expiration_text",
|
||||||
|
{ name: this._formatTokenName(token) }
|
||||||
|
),
|
||||||
|
confirmText: this.hass.localize("ui.common.disable"),
|
||||||
|
destructive: true,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.hass.callWS({
|
||||||
|
type: "auth/refresh_token_set_expiry",
|
||||||
|
refresh_token_id: token.id,
|
||||||
|
enable_expiry: enable,
|
||||||
|
});
|
||||||
|
fireEvent(this, "hass-refresh-tokens");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
typeof err === "object" && err !== null && "message" in err
|
||||||
|
? (err.message as string)
|
||||||
|
: String(err);
|
||||||
|
await showAlertDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
`ui.panel.profile.refresh_tokens.${enable ? "enable" : "disable"}_expiration_failed`
|
||||||
|
),
|
||||||
|
text: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _deleteToken(token: RefreshToken): Promise<void> {
|
||||||
|
if (
|
||||||
|
!(await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.confirm_delete_title"
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.profile.refresh_tokens.confirm_delete_text",
|
||||||
|
{ name: this._formatTokenName(token) }
|
||||||
|
),
|
||||||
|
confirmText: this.hass.localize("ui.common.delete"),
|
||||||
|
destructive: true,
|
||||||
}))
|
}))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -183,6 +328,27 @@ class HaRefreshTokens extends LitElement {
|
|||||||
ha-icon-button {
|
ha-icon-button {
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
|
ha-list-item[disabled],
|
||||||
|
ha-list-item[disabled] ha-svg-icon {
|
||||||
|
color: var(--disabled-text-color) !important;
|
||||||
|
}
|
||||||
|
ha-settings-row .current-session {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
ha-settings-row .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--success-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
ha-settings-row > ha-svg-icon {
|
||||||
|
margin-right: 12px;
|
||||||
|
margin-inline-start: initial;
|
||||||
|
margin-inline-end: 12px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -6371,11 +6371,22 @@
|
|||||||
"refresh_tokens": {
|
"refresh_tokens": {
|
||||||
"header": "Refresh tokens",
|
"header": "Refresh tokens",
|
||||||
"description": "Each refresh token represents a login session. Refresh tokens will be automatically removed when you click log out. Unused refresh tokens will be automatically removed after 90 days. The following refresh tokens are currently active for your account.",
|
"description": "Each refresh token represents a login session. Refresh tokens will be automatically removed when you click log out. Unused refresh tokens will be automatically removed after 90 days. The following refresh tokens are currently active for your account.",
|
||||||
"token_title": "Refresh token for {clientId}",
|
"ios_app": "iOS app",
|
||||||
|
"android_app": "Android app",
|
||||||
|
"current_session": "Your current session",
|
||||||
"created_at": "Created {date}",
|
"created_at": "Created {date}",
|
||||||
"last_used": "Last used {date} from {location}",
|
"last_used": "Last used {date} from {location}",
|
||||||
"not_used": "Has never been used",
|
"not_used": "Has never been used",
|
||||||
"confirm_delete": "Are you sure you want to delete the refresh token for {name}?",
|
"expires_in": "Expires {date}",
|
||||||
|
"never_expires": "Never expires",
|
||||||
|
"disable_token_expiration": "Disable token expiration",
|
||||||
|
"enable_token_expiration": "Enable token expiration",
|
||||||
|
"confirm_disable_token_expiration_title": "Disable token expiration?",
|
||||||
|
"confirm_disable_token_expiration_text": "The refresh token for ''{name}'' will never expire. Disabling token expiration can reduce security. Only disable token expiration on devices that are infrequently used (+90 days between use).",
|
||||||
|
"disable_expiration_failed": "Failed to disable refresh token expiration",
|
||||||
|
"enable_expiration_failed": "Failed to enable refresh token expiration",
|
||||||
|
"confirm_delete_title": "Delete refresh token?",
|
||||||
|
"confirm_delete_text": "The refresh token for ''{name}'' will be permanently deleted. This will end the login session on the associated device.",
|
||||||
"delete_all_tokens": "Delete all tokens",
|
"delete_all_tokens": "Delete all tokens",
|
||||||
"confirm_delete_all": "Are you sure you want to delete all refresh tokens? Your current session token will not be removed. Your long-lived access tokens will not be removed.",
|
"confirm_delete_all": "Are you sure you want to delete all refresh tokens? Your current session token will not be removed. Your long-lived access tokens will not be removed.",
|
||||||
"delete_failed": "Failed to delete the refresh token.",
|
"delete_failed": "Failed to delete the refresh token.",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user