Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@
"Revoke access token?": "Revoke access token?",
"Revoking this token will immediately stop any application or service using it.": "Revoking this token will immediately stop any application or service using it.",
"Access token revoked": "Access token revoked",
"Anniversary calendar (ICS)": "Anniversary calendar (ICS)",
"Subscribe to family anniversaries from your calendar app": "Subscribe to family anniversaries from your calendar app",
"Create a private subscription URL for recurring anniversary events.": "Create a private subscription URL for recurring anniversary events.",
"Subscription URL": "Subscription URL",
"Regenerate link": "Regenerate link",
"Generate link": "Generate link",
"Copy this URL now. It will not be shown again after you leave this page.": "Copy this URL now. It will not be shown again after you leave this page.",
"A subscription link is active. For security, regenerate it to display a new URL.": "A subscription link is active. For security, regenerate it to display a new URL.",
"No active subscription link.": "No active subscription link.",
"Token status unavailable. Retry in Access tokens.": "Token status unavailable. Retry in Access tokens.",
"Manage or revoke this subscription in Access tokens.": "Manage or revoke this subscription in Access tokens.",
"No access token returned by server": "No access token returned by server",
"ICS subscription link updated": "ICS subscription link updated",
"Failed to copy ICS URL to clipboard": "Failed to copy ICS URL to clipboard",
"Change password": "Change password",
"Change username": "Change username",
"Discard": "Discard",
Expand Down
236 changes: 236 additions & 0 deletions src/components/GrampsjsAnniversaryIcsSubscription.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import {LitElement, css, html} from 'lit'

import '@material/web/button/outlined-button'
import '@material/web/textfield/filled-text-field'
import {mdiCheck, mdiContentCopy} from '@mdi/js'

import {__APIHOST__} from '../api.js'
import {GrampsjsAppStateMixin} from '../mixins/GrampsjsAppStateMixin.js'
import {sharedStyles} from '../SharedStyles.js'
import {fireEvent} from '../util.js'
import './GrampsjsIcon.js'

const TOKEN_SCOPE = 'anniversaries_ics'
const TOKEN_ENDPOINT = `/api/users/-/access-tokens/${TOKEN_SCOPE}/`

export class GrampsjsAnniversaryIcsSubscription extends GrampsjsAppStateMixin(
LitElement
) {
static get styles() {
return [
sharedStyles,
css`
.status {
color: var(--md-sys-color-on-surface-variant);
max-width: 65ch;
}

.error {
color: var(--md-sys-color-error);
}

.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}

#anniversary-ics-url {
width: min(100%, 720px);
}
`,
]
}

static get properties() {
return {
tokenStatus: {type: String},
_url: {type: String},
_loading: {type: Boolean},
_copied: {type: Boolean},
_errorMessage: {type: String},
}
}

constructor() {
super()
this.tokenStatus = 'idle'
this._url = ''
this._loading = false
this._copied = false
this._errorMessage = ''
}

updated(changed) {
if (changed.has('tokenStatus') && this.tokenStatus === 'inactive') {
this._url = ''
this._copied = false
this._errorMessage = ''
}
}

render() {
const canGenerate =
!this._loading &&
(this.tokenStatus === 'active' || this.tokenStatus === 'inactive')
return html`
<p>
${this._(
'Create a private subscription URL for recurring anniversary events.'
)}
</p>
<p class="status" aria-live="polite">${this._statusMessage()}</p>
${this._url
? html`
<p>
<md-filled-text-field
id="anniversary-ics-url"
type="url"
label="${this._('Subscription URL')}"
.value="${this._url}"
readonly
autocomplete="off"
></md-filled-text-field>
</p>
`
: ''}
<p class="actions">
<md-outlined-button
@click="${this._generateToken}"
?disabled="${!canGenerate}"
>
${this.tokenStatus === 'active'
? this._('Regenerate link')
: this._('Generate link')}
</md-outlined-button>
<md-outlined-button
@click="${this._copyUrl}"
?disabled="${!this._url || this._loading}"
>
<grampsjs-icon
slot="icon"
path="${this._copied ? mdiCheck : mdiContentCopy}"
color="var(--mdc-theme-primary)"
></grampsjs-icon>
${this._('_Copy')}
</md-outlined-button>
</p>
<p class="status">
${this._('Manage or revoke this subscription in Access tokens.')}
</p>
${this._errorMessage
? html` <p class="error" role="alert">${this._errorMessage}</p> `
: ''}
`
}

_statusMessage() {
if (this._loading) {
return this._('Loading...')
}
if (this._url) {
return this._(
'Copy this URL now. It will not be shown again after you leave this page.'
)
}
switch (this.tokenStatus) {
case 'active':
return this._(
'A subscription link is active. For security, regenerate it to display a new URL.'
)
case 'inactive':
return this._('No active subscription link.')
case 'unavailable':
return this._('Token status unavailable. Retry in Access tokens.')
default:
return this._('Loading...')
}
}

_buildUrl(token, apiHost = __APIHOST__) {
const url = new URL(
`${apiHost}/api/anniversaries.ics`,
window.location.origin
)
url.searchParams.set('token', token)
return url.href
}

async _generateToken() {
if (
this._loading ||
(this.tokenStatus !== 'active' && this.tokenStatus !== 'inactive')
) {
return
}

const previousStatus = this.tokenStatus
this._loading = true
this._copied = false
this._errorMessage = ''
fireEvent(this, 'access-token:changed', {
scope: TOKEN_SCOPE,
status: 'loading',
})
try {
const result = await this.appState.apiPost(
TOKEN_ENDPOINT,
{},
{dbChanged: false}
)
if ('error' in result || !result.data?.token) {
const message =
result.error || this._('No access token returned by server')
this._handleError(message, previousStatus)
return
}
this._url = this._buildUrl(result.data.token)
this._loading = false
fireEvent(this, 'access-token:changed', {
scope: TOKEN_SCOPE,
status: 'active',
})
fireEvent(this, 'grampsjs:notification', {
message: this._('ICS subscription link updated'),
})
} catch (error) {
this._handleError(
error instanceof Error ? error.message : String(error),
previousStatus
)
}
}

_handleError(message, tokenStatus) {
this._errorMessage = message
this._loading = false
fireEvent(this, 'access-token:changed', {
scope: TOKEN_SCOPE,
status: tokenStatus,
})
fireEvent(this, 'grampsjs:error', {message})
}

async _copyUrl() {
if (!this._url) {
return
}
try {
await navigator.clipboard.writeText(this._url)
this._copied = true
setTimeout(() => {
this._copied = false
}, 2000)
} catch {
fireEvent(this, 'grampsjs:error', {
message: this._('Failed to copy ICS URL to clipboard'),
})
}
}
}

window.customElements.define(
'grampsjs-anniversary-ics-subscription',
GrampsjsAnniversaryIcsSubscription
)
30 changes: 30 additions & 0 deletions src/views/GrampsjsViewSettingsUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '@material/web/select/select-option'
import '@material/web/textfield/filled-text-field'

import '../components/GrampsjsCollapsibleSection.js'
import '../components/GrampsjsAnniversaryIcsSubscription.js'
import '../components/GrampsjsFormSelectObjectList.js'
import {userRoles} from '../components/GrampsjsFormUser.js'
import '../components/GrampsjsIcon.js'
Expand Down Expand Up @@ -172,6 +173,24 @@ export class GrampsjsViewSettingsUser extends GrampsjsView {
: ''}
</grampsjs-collapsible-section>
${this._supportsAnniversaryIcs()
? html`
<grampsjs-collapsible-section
title="${this._('Anniversary calendar (ICS)')}"
description="${this._(
'Subscribe to family anniversaries from your calendar app'
)}"
>
<grampsjs-anniversary-ics-subscription
.appState="${this.appState}"
.tokenStatus="${this._accessTokenStates.anniversaries_ics
?.status}"
@access-token:changed="${this._handleAccessTokenChanged}"
></grampsjs-anniversary-ics-subscription>
</grampsjs-collapsible-section>
`
: ''}
<grampsjs-collapsible-section
title="${this._('Appearance')}"
description="${this._('Display preferences saved on this device')}"
Expand Down Expand Up @@ -516,6 +535,10 @@ export class GrampsjsViewSettingsUser extends GrampsjsView {
return apiVersionAtLeast(this.appState?.dbInfo, 3, 18)
}

_supportsAnniversaryIcs() {
return apiVersionAtLeast(this.appState?.dbInfo, 3, 21)
}

_accessTokenEndpoint(scope) {
return `/api/users/-/access-tokens/${encodeURIComponent(scope)}/`
}
Expand All @@ -535,6 +558,13 @@ export class GrampsjsViewSettingsUser extends GrampsjsView {
}
}

_handleAccessTokenChanged(event) {
const {scope, status} = event.detail
if (scope in this._accessTokenStates) {
this._setAccessTokenState(scope, {status, error: ''})
}
}

_loadAccessTokenStatusesIfNeeded(force = false) {
if (!this._supportsPersistentAccessTokens()) {
return
Expand Down
Loading