diff --git a/src/data/refresh_token.ts b/src/data/refresh_token.ts index e5cbfef53b..47edf53937 100644 --- a/src/data/refresh_token.ts +++ b/src/data/refresh_token.ts @@ -11,6 +11,7 @@ export interface RefreshToken { client_id: string; client_name?: string; created_at: string; + expire_at?: string; id: string; is_current: boolean; last_used_at?: string; diff --git a/src/panels/profile/ha-refresh-tokens-card.ts b/src/panels/profile/ha-refresh-tokens-card.ts index b6c340209d..4a0bf2c77c 100644 --- a/src/panels/profile/ha-refresh-tokens-card.ts +++ b/src/panels/profile/ha-refresh-tokens-card.ts @@ -1,15 +1,33 @@ -import { mdiDelete } from "@mdi/js"; 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 memoizeOne from "memoize-one"; import { relativeTime } from "../../common/datetime/relative_time"; import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-button-menu"; import "../../components/ha-card"; -import "../../components/ha-settings-row"; 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 { RefreshToken } from "../../data/refresh_token"; import { showAlertDialog, showConfirmationDialog, @@ -17,6 +35,11 @@ import { import { haStyle } from "../../resources/styles"; 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 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; @@ -38,95 +61,217 @@ class HaRefreshTokens extends LitElement { private _refreshTokens = memoizeOne( (refreshTokens: RefreshToken[]): RefreshToken[] => refreshTokens - ?.filter((token) => token.type === "normal") + .filter((token) => token.type === "normal") .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 { - const refreshTokens = this._refreshTokens(this.refreshTokens!); - return html` -
- ${this.hass.localize("ui.panel.profile.refresh_tokens.description")} - ${refreshTokens?.length - ? refreshTokens!.map( - (token) => - html` - ${this.hass.localize( - "ui.panel.profile.refresh_tokens.token_title", - { clientId: token.client_id } - )} - -
- ${this.hass.localize( - "ui.panel.profile.refresh_tokens.created_at", - { - date: relativeTime( - new Date(token.created_at), - this.hass.locale - ), - } - )} -
-
- ${token.last_used_at - ? this.hass.localize( - "ui.panel.profile.refresh_tokens.last_used", - { - date: relativeTime( - new Date(token.last_used_at), - this.hass.locale - ), - location: token.last_used_ip, - } - ) - : this.hass.localize( - "ui.panel.profile.refresh_tokens.not_used" - )} -
-
- ${token.is_current - ? html` - ${this.hass.localize( - "ui.panel.profile.refresh_tokens.current_token_tooltip" + const refreshTokens = this.refreshTokens + ? this._refreshTokens(this.refreshTokens) + : []; + return html` + +
+ ${this.hass.localize("ui.panel.profile.refresh_tokens.description")} + ${refreshTokens.length + ? refreshTokens.map( + (token) => html` + + + + ${this._formatTokenName(token)} + +
+ ${this.hass.localize( + "ui.panel.profile.refresh_tokens.created_at", + { + date: relativeTime( + new Date(token.created_at), + this.hass.locale + ), + } + )} +
+
+ ${token.is_current + ? html` + + ${this.hass.localize( + "ui.panel.profile.refresh_tokens.current_session" + )} + + ` + : token.last_used_at + ? this.hass.localize( + "ui.panel.profile.refresh_tokens.last_used", + { + date: relativeTime( + new Date(token.last_used_at), + this.hass.locale + ), + location: token.last_used_ip, + } + ) + : this.hass.localize( + "ui.panel.profile.refresh_tokens.not_used" + )} +
+
+ ${token.expire_at + ? this.hass.localize( + "ui.panel.profile.refresh_tokens.expires_in", + { + date: relativeTime( + new Date(token.expire_at), + this.hass.locale + ), + } + ) + : this.hass.localize( + "ui.panel.profile.refresh_tokens.never_expires" )} - ` - : ""} - -
-
` - ) - : ""} -
-
- - ${this.hass.localize( - "ui.panel.profile.refresh_tokens.delete_all_tokens" - )} - -
-
`; +
+
+ + + + + ${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" + )} + + + + ${this.hass.localize("ui.common.delete")} + + +
+
+ ` + ) + : nothing} +
+
+ + ${this.hass.localize( + "ui.panel.profile.refresh_tokens.delete_all_tokens" + )} + +
+
+ `; } - private async _deleteToken(ev: Event): Promise { + private async _handleAction(ev: CustomEvent) { 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 { + const enable = !token.expire_at; + if (!enable) { + if ( + !(await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.profile.refresh_tokens.confirm_disable_token_expiration_title" + ), + 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 { if ( !(await showConfirmationDialog(this, { - text: this.hass.localize( - "ui.panel.profile.refresh_tokens.confirm_delete", - { name: token.client_name || token.client_id } + 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; @@ -183,6 +328,27 @@ class HaRefreshTokens extends LitElement { ha-icon-button { 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; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index 17437325a6..4c66f6dfc6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6371,11 +6371,22 @@ "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.", - "token_title": "Refresh token for {clientId}", + "ios_app": "iOS app", + "android_app": "Android app", + "current_session": "Your current session", "created_at": "Created {date}", "last_used": "Last used {date} from {location}", "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", "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.",