Skip to content
Merged
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
6 changes: 6 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ watch(() => route.fullPath, () => {
>
Accessibility
</NuxtLink>
<NuxtLink
to="/privacy"
class="font-mono text-xs text-muted underline-offset-2 hover:underline hover:text-default"
>
Privacy
</NuxtLink>
<UButton
icon="i-lucide-keyboard"
color="neutral"
Expand Down
149 changes: 149 additions & 0 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script setup lang="ts">
import type {
IdentityAccountDeleteBody,
IdentityAccountDeleteResponse,
IdentityEmailPatchBody,
IdentityMutationResponse,
IdentitySubscriptionRef,
} from '../../../shared/contracts/account'
import type { StarsSyncResponse } from '../../utils/sync-starred-repos'
import { avatarProxyUrl } from '#shared/image-proxy'
import { accountDeletionConfirmed } from '../../../shared/contracts/account'
import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account'
import { syncStarredRepos } from '../../utils/sync-starred-repos'

Expand Down Expand Up @@ -161,6 +164,58 @@ function fmtDate(ts: number | null | undefined): string {
timeZone: me.value?.timezone ?? 'UTC',
}).format(new Date(ts * 1000))
}

const { clear: clearSession } = useUserSession()
const deleteDialogOpen = ref(false)
const deleteConfirmation = ref('')
const deleteFailed = ref(false)
const deleteConfirmed = computed(() => !!me.value && accountDeletionConfirmed(deleteConfirmation.value, me.value.login))
// The RPC client sends no body with DELETE, and this request needs the typed login.
const deleteAccountMutation = useNuxtMutation<IdentityAccountDeleteBody, IdentityAccountDeleteResponse>({
mutation: body => $fetch<IdentityAccountDeleteResponse>('/api/me', { method: 'DELETE', body }),
})

watch(deleteDialogOpen, (open) => {
if (!open) {
deleteConfirmation.value = ''
deleteFailed.value = false
}
})

async function deleteAccount() {
if (!deleteConfirmed.value || deleteAccountMutation.pending.value)
return
deleteFailed.value = false
const deleted = await deleteAccountMutation.mutateSafe({ confirm_login: deleteConfirmation.value })
if (deleted._tag === 'err') {
deleteFailed.value = true
return
}

await clearSession()
toast.add(deleted.data.github_access_revoked
? {
title: 'Your account is deleted',
description: 'skilld no longer has access to your GitHub account.',
color: 'success',
icon: 'i-lucide-check-circle',
}
: {
title: 'Your account is deleted',
description: 'GitHub did not confirm that skilld lost access. Revoke skilld in your GitHub settings.',
color: 'warning',
icon: 'i-lucide-triangle-alert',
duration: 20_000,
actions: [{
label: 'Open GitHub settings',
to: 'https://github.com/settings/applications',
target: '_blank',
color: 'neutral',
variant: 'outline',
}],
})
await navigateTo('/')
}
</script>

<template>
Expand Down Expand Up @@ -463,7 +518,101 @@ function fmtDate(ts: number | null | undefined): string {
class="min-h-11"
/>
</nav>

<section class="border-t border-default pt-6" aria-labelledby="delete-account-heading">
<h2 id="delete-account-heading" class="text-lg font-semibold">
Delete account
</h2>
<p class="mt-2 text-sm leading-relaxed text-muted">
Deleting your account removes your profile, likes, watched Repositories, collections, email settings, and CLI devices.
<NuxtLink to="/privacy" class="text-default underline underline-offset-2 hover:text-primary">
See what skilld stores
</NuxtLink>
</p>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash-2"
label="Delete account"
class="mt-4 min-h-11"
@click="deleteDialogOpen = true"
/>
</section>
</aside>
</div>

<UModal
v-model:open="deleteDialogOpen"
title="Delete your account"
description="You cannot undo this."
:dismissible="!deleteAccountMutation.pending.value"
:close="{ size: 'md', class: 'min-h-11 min-w-11 justify-center', disabled: deleteAccountMutation.pending.value }"
:ui="{
content: 'w-[calc(100vw-1.5rem)] max-w-lg rounded-lg border border-default bg-default shadow-none',
header: 'px-5 py-5 sm:px-6',
body: 'px-5 py-5 sm:px-6',
footer: 'justify-end gap-2 px-5 py-4 sm:px-6',
title: 'font-mono text-base font-medium text-highlighted',
description: 'text-sm text-muted',
}"
>
<template #body>
<form id="delete-account-form" class="space-y-5" @submit.prevent="deleteAccount">
<div>
<h3 class="section-label">
skilld deletes
</h3>
<ul class="mt-3 list-disc space-y-2 ps-5 text-sm leading-relaxed marker:text-muted">
<li>Your GitHub profile details and email address</li>
<li>Email settings and email history</li>
<li>Liked Skills, watched Repositories, and imported stars</li>
<li>Your collections</li>
<li>CLI devices and tokens</li>
</ul>
</div>
<p class="text-sm leading-relaxed text-muted">
skilld also asks GitHub to revoke its access to your account.
Skills in your public Repositories stay listed, because GitHub is their source.
</p>
<UFormField :label="`Type ${me?.login} to confirm`" name="confirm_login">
<UInput
v-model="deleteConfirmation"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
class="w-full font-mono"
:ui="{ base: 'min-h-11' }"
:disabled="deleteAccountMutation.pending.value"
/>
</UFormField>
<p v-if="deleteFailed" class="text-sm text-error" role="alert">
Could not delete your account. Check your connection and try again.
</p>
</form>
</template>

<template #footer>
<UButton
color="neutral"
variant="outline"
label="Cancel"
class="min-h-11"
:disabled="deleteAccountMutation.pending.value"
@click="deleteDialogOpen = false"
/>
<!-- Subtle, not solid: white text on the dark mode error color is below 4.5:1. -->
<UButton
type="submit"
form="delete-account-form"
color="error"
variant="subtle"
icon="i-lucide-trash-2"
label="Delete account"
class="min-h-11"
:disabled="!deleteConfirmed"
:loading="deleteAccountMutation.pending.value"
/>
</template>
</UModal>
</section>
</template>
53 changes: 53 additions & 0 deletions layers/identity/server/api/me/index.delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { defineApiHandler } from '#shared/server/handler'
import {
accountDeletionConfirmed,
identityAccountDeleteBodySchema,
identityAccountDeleteResponseSchema,
} from '../../../shared/contracts/account'
import { browserSession } from '../../policies/browser-session'
import { deleteAccountData, loadGithubGrant, revokeGithubGrant } from '../../utils/account-deletion'
import { requireUserRow } from '../../utils/users'

/**
* Delete the signed-in account.
*
* The request must carry the sign-in cookie and the GitHub login typed by the
* person. Local data goes first, in one transaction. The GitHub grant is
* revoked after that, and a failed revocation still returns success, because
* the account no longer exists.
*/
export default defineApiHandler({
schema: identityAccountDeleteBodySchema,
policy: [browserSession],
response: identityAccountDeleteResponseSchema,
handler: async ({ event, body, platform }) => {
const account = await requireUserRow(event)
if (!accountDeletionConfirmed(body.confirm_login, account.login)) {
throw createError({
statusCode: 400,
statusMessage: 'Confirmation does not match',
message: `Type ${account.login} to delete your account.`,
})
}

const { db, env } = platform
// The token lives on the row this request deletes, so read it first.
const grant = await loadGithubGrant(db, account.id, env.NUXT_TOKEN_KEY)
const deletedRows = await deleteAccountData(db, account.id, Math.floor(Date.now() / 1000))
const revocation = await revokeGithubGrant(grant, {
clientId: env.NUXT_OAUTH_GITHUB_CLIENT_ID,
clientSecret: env.NUXT_OAUTH_GITHUB_CLIENT_SECRET,
}, fetch)

emitOperationalEvent(createWideEvent({
'operation': 'account-deletion',
'outcome': revocation._tag,
'reason': revocation._tag === 'skipped' ? revocation.reason : undefined,
'upstream.status': revocation._tag === 'failed' ? revocation.status : undefined,
'success.count': deletedRows,
}), revocation._tag === 'revoked' ? 'info' : 'warn')

await clearUserSession(event)
return { ok: true as const, github_access_revoked: revocation._tag === 'revoked' }
},
})
9 changes: 9 additions & 0 deletions layers/identity/server/policies/browser-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Policy } from '#shared/server/handler'

/**
* The request carries a skilld.dev sign-in cookie.
*
* A CLI bearer token alone does not pass. A token copied off a machine must
* not be able to delete the account it belongs to.
*/
export const browserSession: Policy<any> = ({ session }) => typeof session?.user?.id === 'number'
Loading
Loading