Files
modrinth/apps/frontend/src/components/ui/auth/SignIn.vue
T
Calum H.andProspector bb2193b6f5 refactor: Button components (#6929)
* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 13:54:04 -07:00

318 lines
8.9 KiB
Vue

<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div
v-else
class="universal-card mx-auto flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<template v-if="flow && !subtleLauncherRedirectUri">
<div class="flex flex-col items-end gap-4">
<div class="flex flex-col gap-1.5">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCodeModel"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
@keyup.enter="onTwoFactorSignIn()"
/>
</div>
<Button type="colored" color="brand" @click="onTwoFactorSignIn()">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</Button>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-5">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<ButtonLink
v-for="provider in oauthProviders"
:key="provider.id"
class="relative w-full !justify-center overflow-visible"
:class="{
'!border !border-[var(--color-green)]': lastSignInProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget)"
:aria-label="formatMessage(messages.continueWithProvider, { provider: provider.name })"
@click="onOAuthProviderClick(provider.id)"
>
<component :is="provider.icon" />
<span>{{ provider.name }}</span>
<span
v-if="lastSignInProvider === provider.id"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</ButtonLink>
<Button
class="relative !w-full !justify-center overflow-visible sm:col-span-2"
:class="{ '!border !border-[var(--color-green)]': lastSignInProvider === 'passkey' }"
role="button"
tabindex="0"
@click="onPasskeySignIn"
@keydown.enter="onPasskeySignIn"
>
<UserKeyIcon />
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
<span v-if="lastSignInProvider === 'passkey'" class="oauth-provider-last-sign-in-badge">
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</Button>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="mx-auto flex w-full flex-col gap-2.5">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<StyledInput
id="email"
v-model="emailModel"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha
v-if="globals?.captcha_enabled && emailModel && passwordModel"
:ref="onSetCaptchaRef"
v-model="tokenModel"
/>
<Button
type="colored"
color="brand"
class="!w-full"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onPasswordSignIn()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</Button>
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: routeQuery,
}"
>
{{ formatMessage(messages.forgotPasswordLabel) }}
</NuxtLink>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<NuxtLink
class="inline text-link"
:to="{
path: '/auth/sign-up',
query: routeQuery,
}"
>
{{ formatMessage(messages.createAccountLabel) }}
</NuxtLink>
</div>
</section>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
UserKeyIcon,
} from '@modrinth/assets'
import {
Button,
ButtonLink,
commonMessages,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import type { LocationQuery } from 'vue-router'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import {
getAuthUrl,
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
} from '@/composables/auth.ts'
const oauthProviders = [
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
{ id: 'github', name: 'GitHub', icon: GitHubColorIcon },
{ id: 'microsoft', name: 'Microsoft', icon: MicrosoftColorIcon },
{ id: 'google', name: 'Google', icon: GoogleColorIcon },
{ id: 'steam', name: 'Steam', icon: SteamColorIcon },
{ id: 'gitlab', name: 'GitLab', icon: GitLabColorIcon },
] as const
type AuthProvider = (typeof oauthProviders)[number]['id'] | 'passkey'
interface AuthGlobals {
captcha_enabled?: boolean
[key: string]: unknown
}
interface Props {
subtleLauncherRedirectUri?: string
flow?: string
redirectTarget?: string
routeQuery?: LocationQuery
globals?: AuthGlobals | null
onPasswordSignIn?: () => void
onTwoFactorSignIn?: () => void
onPasskeySignIn?: () => void
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
}
const {
subtleLauncherRedirectUri = '',
flow = '',
redirectTarget = '',
routeQuery = {},
globals = null,
onPasswordSignIn = () => {},
onTwoFactorSignIn = () => {},
onPasskeySignIn = () => {},
onSetCaptchaRef = undefined,
} = defineProps<Props>()
const emailModel = defineModel<string>('email', { default: '' })
const passwordModel = defineModel<string>('password', { default: '' })
const tokenModel = defineModel<string>('token', { default: '' })
const twoFactorCodeModel = defineModel<string>('twoFactorCode', { default: '' })
const lastSignInOAuthProvider = useStorage<AuthProvider | null>(
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const lastSignInProvider = computed(() => lastSignInOAuthProvider.value)
const onOAuthProviderClick = (provider: AuthProvider) => {
pendingSignInOAuthProvider.value = provider
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
forgotPasswordLabel: {
id: 'auth.sign-in.forgot-password',
defaultMessage: 'Forgot password',
},
noAccountLabel: {
id: 'auth.sign-in.no-account',
defaultMessage: "Don't have an account?",
},
createAccountLabel: {
id: 'auth.sign-in.create-account',
defaultMessage: 'Sign up',
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign into Modrinth',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Two-factor authentication',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage:
'Enter the 6-digit code from your authenticator app, or one of your backup codes.',
},
continueWithProvider: {
id: 'auth.continue-with-provider',
defaultMessage: 'Continue with {provider}',
},
continueWithEmail: {
id: 'auth.sign-in.continue-with-email',
defaultMessage: 'Continue with Email',
},
lastSignInLabel: {
id: 'auth.sign-in.last-sign-in',
defaultMessage: 'Last used',
},
continueWithPasskey: {
id: 'auth.sign-in.continue-with-passkey',
defaultMessage: 'Continue with passkey',
},
})
</script>
<style scoped lang="scss">
.oauth-provider-last-sign-in-badge {
position: absolute;
top: -0.75rem;
right: 0.25rem;
z-index: 1;
border-radius: 9999px;
background-color: var(--surface-3);
color: var(--color-green);
border: 1px solid var(--color-green);
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
pointer-events: none;
}
.oauth-provider-last-sign-in-badge::before {
content: '';
inset: 0;
border-radius: inherit;
background-color: var(--color-green-highlight);
position: absolute;
z-index: 0;
}
</style>