mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Compte à mot de passe : relier le fournisseur OIDC soi-même (#161)
* OIDC : un compte à mot de passe relie lui-même son identité du fournisseur (ticket à usage unique), pour passer une instance en PASSWORD_LOGIN=false sans l'enfermer dehors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Compte : « Se connecter aussi avec … » relie le fournisseur à un compte à mot de passe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README : relier un compte à mot de passe avant PASSWORD_LOGIN=false Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
856a80d111
commit
f1b4ad8db2
13 files changed
+221
-12
No files matched your search
@@ -97,7 +97,7 @@ OIDC_PROVIDER_NAME=Authentik # shown on
|
||||
|
||||
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
|
||||
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
|
||||
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export type OidcPending = {
|
||||
nonce: string
|
||||
codeVerifier: string
|
||||
returnTo: string
|
||||
// Rattachement demandé depuis un compte connecté (ticket de /auth/oidc/link) : l'identité du fournisseur rejoint
|
||||
// ce compte, au lieu d'en chercher un par email
|
||||
linkUserId?: string
|
||||
}
|
||||
|
||||
export type OidcIdentity = {
|
||||
|
||||
@@ -254,14 +254,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: request.actor.userId },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } })
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe.
|
||||
// oidcLinked : le compte se connecte déjà par le fournisseur ; sinon le compte propose « Relier à … »
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash), oidcLinked: (user.identities?.length ?? 0) > 0 } })
|
||||
})
|
||||
|
||||
app.get('/me/last-tree', async (request, reply) => {
|
||||
|
||||
@@ -46,8 +46,42 @@ const pendingSchema = z.object({
|
||||
nonce: z.string().min(1),
|
||||
codeVerifier: z.string().min(43),
|
||||
returnTo: z.string(),
|
||||
linkUserId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
// Rattacher une identité du fournisseur à un compte à mot de passe. Le compte connecté demande un ticket (court, à
|
||||
// usage unique), le départ OIDC le porte, et le retour relie l'identité à ce compte. La personne prouve les deux :
|
||||
// son mot de passe (session) et son compte chez le fournisseur. C'est le chemin pour passer une instance en
|
||||
// PASSWORD_LOGIN=false sans enfermer dehors les comptes à mot de passe : le rattachement automatique par email reste
|
||||
// refusé pour eux (resolveAccount), parce que l'inscription par mot de passe ne vérifie pas l'adresse.
|
||||
const LINK_TICKET_TTL_S = 120
|
||||
type LinkTicketPayload = { kind: 'oidc_link'; sub: string; jti: string; exp?: number }
|
||||
const usedLinkTickets = new Map<string, number>()
|
||||
function consumeLinkTicket(jti: string, expSeconds: number): boolean {
|
||||
const now = Date.now()
|
||||
for (const [key, expiresAt] of usedLinkTickets) {
|
||||
if (expiresAt < now) usedLinkTickets.delete(key)
|
||||
}
|
||||
if (usedLinkTickets.has(jti)) return false
|
||||
usedLinkTickets.set(jti, expSeconds * 1000)
|
||||
return true
|
||||
}
|
||||
|
||||
type LinkResult = { userId: string } | { error: 'oidc_identity_taken' | 'oidc_failed' }
|
||||
|
||||
async function linkIdentity(app: FastifyInstance, userId: string, identity: OidcIdentity): Promise<LinkResult> {
|
||||
const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
|
||||
if (!user) return { error: 'oidc_failed' }
|
||||
const linked = await app.prisma.userIdentity.findUnique({
|
||||
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
|
||||
select: { userId: true },
|
||||
})
|
||||
// Déjà reliée à ce compte : rien à faire. Reliée à un autre : jamais déplacée d'un compte à l'autre
|
||||
if (linked) return linked.userId === userId ? { userId } : { error: 'oidc_identity_taken' }
|
||||
await app.prisma.userIdentity.create({ data: { userId, issuer: identity.issuer, subject: identity.subject } })
|
||||
return { userId }
|
||||
}
|
||||
|
||||
const exchangeSchema = z.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
})
|
||||
@@ -114,7 +148,18 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
'/start',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
|
||||
const query = (request.query as Record<string, unknown> | undefined) ?? {}
|
||||
// Ticket de rattachement : vérifié et consommé ici, le compte voyage dans le cookie signé
|
||||
let linkUserId: string | undefined
|
||||
if (typeof query.link === 'string') {
|
||||
let ticket: LinkTicketPayload | null = null
|
||||
try { ticket = app.jwt.verify<LinkTicketPayload>(query.link) } catch { ticket = null }
|
||||
if (!ticket || ticket.kind !== 'oidc_link' || typeof ticket.exp !== 'number' || !consumeLinkTicket(ticket.jti, ticket.exp)) {
|
||||
return redirectToApp(reply, '/', 'oidc_error=oidc_link_expired')
|
||||
}
|
||||
linkUserId = ticket.sub
|
||||
}
|
||||
const returnTo = linkUserId ? '/' : safeReturnTo(query.returnTo)
|
||||
let start: Awaited<ReturnType<typeof startAuthorization>>
|
||||
try {
|
||||
start = await startAuthorization(returnTo)
|
||||
@@ -123,7 +168,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
|
||||
}
|
||||
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(linkUserId ? { ...start.pending, linkUserId } : start.pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
|
||||
@@ -167,13 +212,13 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
let result: AccountResult
|
||||
let result: AccountResult | LinkResult
|
||||
try {
|
||||
result = await resolveAccount(app, identity)
|
||||
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
|
||||
} catch (error) {
|
||||
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
result = await resolveAccount(app, identity)
|
||||
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
@@ -195,6 +240,23 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
)
|
||||
|
||||
// Ticket de rattachement pour le compte connecté (session par en-tête) : la navigation vers /start ne peut pas
|
||||
// porter l'en-tête, le ticket le remplace, deux minutes et une seule fois
|
||||
app.post(
|
||||
'/link',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
const ticket = app.jwt.sign(
|
||||
{ kind: 'oidc_link', sub: request.actor.userId, jti: randomUUID() },
|
||||
{ expiresIn: `${LINK_TICKET_TTL_S}s` },
|
||||
)
|
||||
return reply.send({ ticket })
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/exchange',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
|
||||
@@ -35,7 +35,15 @@ export type OidcHandoffJwtPayload = {
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
|
||||
// Ticket de rattachement d'une identité OIDC au compte connecté (120 s, à usage unique, porté par /auth/oidc/start) :
|
||||
// jamais une session, le décodeur de l'en-tête ne retient que les sortes user et tree_access
|
||||
export type OidcLinkJwtPayload = {
|
||||
kind: 'oidc_link'
|
||||
sub: string
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload | OidcLinkJwtPayload
|
||||
|
||||
export type UserActor = {
|
||||
kind: 'user'
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('prénom du compte', () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
|
||||
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true, oidcLinked: false })
|
||||
|
||||
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
|
||||
@@ -337,6 +337,89 @@ describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () =>
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('rattacher le fournisseur à un compte à mot de passe (avant PASSWORD_LOGIN=false)', () => {
|
||||
const sessionOf = (userId: string) => app.jwt.sign({ kind: 'user', sub: userId, jti: `s-${userId}`, userId, email: 'anais@example.com' })
|
||||
|
||||
async function ticketFor(userId: string) {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', headers: { authorization: `Bearer ${sessionOf(userId)}` }, remoteAddress: nextAddress() })
|
||||
expect(res.statusCode).toBe(200)
|
||||
return res.json().ticket as string
|
||||
}
|
||||
|
||||
async function startLink(ticket: string) {
|
||||
currentAddress = nextAddress()
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?link=${encodeURIComponent(ticket)}&returnTo=${encodeURIComponent('/arbre/abc')}`, remoteAddress: currentAddress })
|
||||
return { res, cookie: String(res.headers['set-cookie']).split(';')[0] }
|
||||
}
|
||||
|
||||
it('sans session, pas de ticket', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', remoteAddress: nextAddress() })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("relie l'identité au compte qui a demandé le ticket, même avec un mot de passe et sans regarder l'email", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { res: started, cookie } = await startLink(ticket)
|
||||
expect(started.statusCode).toBe(302)
|
||||
// Le retour de l'app ne vient pas de la requête : un rattachement revient toujours à la racine
|
||||
expect(oidcMock.startAuthorization).toHaveBeenCalledWith('/')
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'autre@example.com', emailVerified: false })
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-pw', issuer: ISSUER, subject: 'sub-1' } })
|
||||
const code = fragmentOf(res.headers.location).get('oidc') as string
|
||||
expect(app.jwt.decode<{ sub: string }>(code)?.sub).toBe('user-pw')
|
||||
// Le rattachement ne passe jamais par la recherche d'un compte par email ni par la création d'un compte
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("une identité déjà reliée à un autre compte n'est jamais déplacée", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-other' })
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_identity_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('déjà reliée à ce compte : connexion, rien de recréé', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-pw' })
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('le ticket sert une fois, et un faux ticket ou une session ne lancent rien', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
expect((await startLink(ticket)).res.statusCode).toBe(302)
|
||||
const again = await startLink(ticket)
|
||||
expect(fragmentOf(again.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
const forged = await startLink('nimportequoi')
|
||||
expect(fragmentOf(forged.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
const session = await startLink(sessionOf('user-pw'))
|
||||
expect(fragmentOf(session.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
})
|
||||
|
||||
it("le ticket n'ouvre aucune session", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${ticket}` } })
|
||||
expect(asBearer.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('compte supprimé entre le ticket et le retour : refusé', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journaux', () => {
|
||||
it("le code d'autorisation du retour n'est pas journalisé", async () => {
|
||||
const { redactUrl } = await import('../src/app.js')
|
||||
|
||||
+10
-1
@@ -54,7 +54,7 @@ import {
|
||||
uploadPersonMedia,
|
||||
} from './api/treeApi'
|
||||
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { fetchAuthOptions, oidcStartUrl } from './api/authApi'
|
||||
import { fetchAuthOptions, oidcLinkUrl, oidcStartUrl, requestOidcLinkTicket } from './api/authApi'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
|
||||
import { useMediaManager } from './hooks/useMediaManager'
|
||||
@@ -3181,6 +3181,15 @@ function App() {
|
||||
oidcName={authOptions.oidc?.name || ''}
|
||||
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined}
|
||||
hasPassword={auth.userAuth.user?.hasPassword !== false}
|
||||
oidcLinked={auth.userAuth.user?.oidcLinked === true}
|
||||
onLinkOidc={authOptions.oidc && auth.userAuth.user?.oidcLinked === false ? async () => {
|
||||
// Relier le fournisseur au compte : le serveur donne un ticket court, le départ OIDC le porte
|
||||
try {
|
||||
window.location.assign(oidcLinkUrl(await requestOidcLinkTicket(auth.userAuth.token)))
|
||||
} catch (error) {
|
||||
auth.setAccountError(getAccountErrorMessage(error))
|
||||
}
|
||||
} : undefined}
|
||||
pendingTreeName={pendingSharedTreeName}
|
||||
onLogin={handleLogin}
|
||||
onRegister={handleRegister}
|
||||
|
||||
@@ -88,6 +88,22 @@ export function oidcStartUrl(returnTo = '/') {
|
||||
return `${API_BASE_URL}/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
// Relier le fournisseur au compte connecté : un ticket de deux minutes, à usage unique, puis le départ OIDC qui le porte
|
||||
export async function requestOidcLinkTicket(token) {
|
||||
const data = await apiRequest('/auth/oidc/link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
return data?.ticket
|
||||
}
|
||||
|
||||
export function oidcLinkUrl(ticket) {
|
||||
return `${API_BASE_URL}/auth/oidc/start?link=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
// Jeton de passage reçu au retour du fournisseur, échangé une seule fois contre une session
|
||||
export async function exchangeOidcCode(code) {
|
||||
return apiRequest('/auth/oidc/exchange', {
|
||||
|
||||
@@ -29,6 +29,10 @@ const AccountDashboard = ({
|
||||
oidcName = '',
|
||||
onOidcLogin,
|
||||
hasPassword = true,
|
||||
// Compte à mot de passe sur une instance OIDC : « Se connecter aussi avec … » relie le fournisseur, pour que le compte
|
||||
// reste accessible quand l'instance passe en connexion par le fournisseur seule. oidcLinked : déjà fait
|
||||
onLinkOidc,
|
||||
oidcLinked = false,
|
||||
pendingTreeName = '',
|
||||
onLogin,
|
||||
onRegister,
|
||||
@@ -305,7 +309,14 @@ const AccountDashboard = ({
|
||||
{t('Essayer la démo')}
|
||||
</button>
|
||||
)}
|
||||
{onLinkOidc && !oidcLinked && (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm account-oidc-link" onClick={onLinkOidc} disabled={loading}>
|
||||
<LogIn size={15} aria-hidden="true" />
|
||||
{t('Se connecter aussi avec {name}', { name: oidcName })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{oidcLinked && oidcName && <p className="account-oidc-linked">{t('Connexion avec {name} reliée à ce compte.', { name: oidcName })}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pz-account-foot">
|
||||
|
||||
@@ -70,3 +70,6 @@
|
||||
/* Arbre avec une icône choisie : l'emoji seul, sans la pastille de couleur de la lettre */
|
||||
.pz-tree-mono--emoji { background: transparent; font-size: 22px; line-height: 1; }
|
||||
.invite-tree-icon { font-size: 30px; line-height: 1; }
|
||||
|
||||
/* Connexion par le fournisseur reliée au compte (OIDC) */
|
||||
.account-oidc-linked { margin: 8px 0 0; font: 400 13px var(--pz-sans); color: var(--pz-muted); }
|
||||
@@ -247,4 +247,9 @@ export default {
|
||||
"Aide": "Help",
|
||||
"Se déconnecter": "Sign out",
|
||||
"Partager l'arbre": "Share the tree",
|
||||
// Relier le fournisseur OIDC à un compte à mot de passe
|
||||
'Se connecter aussi avec {name}': 'Also sign in with {name}',
|
||||
'Connexion avec {name} reliée à ce compte.': 'Sign-in with {name} is linked to this account.',
|
||||
'Ce compte du fournisseur est déjà relié à un autre compte Parenthèse.': 'This provider account is already linked to another Parenthèse account.',
|
||||
'Le lien pour relier le compte a expiré. Recommencez depuis votre compte.': 'The link to connect your account has expired. Start again from your account.',
|
||||
}
|
||||
@@ -92,6 +92,14 @@ export function getAccountErrorMessage(error) {
|
||||
return t("Votre fournisseur de connexion n'indique pas que votre adresse email est vérifiée. Vérifiez-la chez lui, puis réessayez.")
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_identity_taken') {
|
||||
return t('Ce compte du fournisseur est déjà relié à un autre compte Parenthèse.')
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_link_expired') {
|
||||
return t('Le lien pour relier le compte a expiré. Recommencez depuis votre compte.')
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_email_taken') {
|
||||
return t("Un compte avec mot de passe existe déjà pour cette adresse. Connectez-vous avec votre email et votre mot de passe.")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user