mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #123 from IliesAllali/release/1.2.0
Release 1.2.0 : changelog, OIDC expérimental, jamais relié à un compte à mot de passe
This commit is contained in:
8 files changed
+68
-7
No files matched your search
@@ -2,6 +2,39 @@
|
||||
|
||||
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
|
||||
|
||||
## 1.2.0 (2026-10-20)
|
||||
|
||||
### Tree layout
|
||||
|
||||
- Rows follow the kinship generation from the tree's starting person: spouses on the same row, parents one row up, children one row down. A shorter branch married into a deeper one no longer sits several rows too high (#67, reported by @SpaceOptimist, #111).
|
||||
- One tree, never islands. An in-law family attaches to its child at the end of the sibling row, never between two siblings, and each union of a remarriage sits above its own children (#105, #116).
|
||||
- In-law families wider than a screen open in their own tab, the same tree drawn from the spouse. A pill above the spouse leads there and back, and tabs with the same name show the couple's first names. Search opens the tab holding the person. Tree settings can turn tabs off (#116, #81 reported by @coutadeurf).
|
||||
- Lines between families are full lines when they cross no other family, with one trunk per sibling row (#83, #90).
|
||||
|
||||
### People
|
||||
|
||||
- Sex can be set to female, male or other, and is unspecified by default. Relationship names follow it in French and English, with neutral or double forms when unknown. GEDCOM `SEX X` is read and written (#114, suggested by @Arc676, #118).
|
||||
- Nicknames show under the name in the tree and the profile (#85). The last name is optional and an unknown first name can be ticked (#87, #89).
|
||||
- Profiles: pills for facts, place of death, adoption, autosave when leaving, a cleared field stays cleared, close by swiping down on phones (#91, #93, #98, #101, #106).
|
||||
- Portraits can be cropped with the full photo kept, in a round or square frame, and "+ photo" opens the picker right away (#95, #108, #109). Admins can edit a memory (#94).
|
||||
|
||||
### Sharing and family
|
||||
|
||||
- Guests can propose changes with the pencil, and the owner sees pending proposals on their avatar as soon as the tree opens (#96, #121).
|
||||
- A simpler start page with two choices, one door for shared trees, and a clearer login (#86, #88, #92).
|
||||
- The bottom bar is one pill with Add or correct and the account menu (#100, #104, #107). Search results can be tapped in edit mode on phones (#110).
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- `ALLOW_REGISTRATION=false` closes sign-up. Existing accounts log in and the first account of an empty instance is still allowed (#113, suggested by @milan616, #117).
|
||||
- OIDC login (experimental) with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URI`. An existing account is linked by verified email only, and never an account that has a password (sign-up does not verify the address). `PASSWORD_LOGIN=false` turns passwords off (#115, suggested by @Arc676, #122).
|
||||
|
||||
### Fixes and maintenance
|
||||
|
||||
- GEDCOM import in transactions and much faster on large files, backups restore tested in CI, and other points from the 18/10 audit (#97). A union without a date sits at its logical place in the Life tab (#102).
|
||||
- Mobile: separated parents drawn as a dotted line, lighter edit bar, dates on the number pad (#82).
|
||||
- Eleven unused interface modules removed (#119).
|
||||
|
||||
## 1.1.0 (2026-10-17)
|
||||
|
||||
### Life tab
|
||||
|
||||
@@ -61,6 +61,8 @@ Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y crée
|
||||
|
||||
#### Connexion par votre fournisseur d'identité (OIDC)
|
||||
|
||||
*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.*
|
||||
|
||||
Parenthèse peut confier la connexion à un fournisseur OpenID Connect que vous faites déjà tourner (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…) : la famille garde un seul compte et sa double authentification. Créez chez lui une application OIDC (client confidentiel, flux « authorization code ») dont l'adresse de retour est l'adresse publique de Parenthèse suivie de `/auth/oidc/callback`, puis renseignez dans `.env` :
|
||||
|
||||
```bash
|
||||
|
||||
@@ -67,6 +67,8 @@ So that people who come across the address can't sign up and start their own tre
|
||||
|
||||
#### Sign in with your identity provider (OIDC)
|
||||
|
||||
*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.*
|
||||
|
||||
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -50,7 +50,7 @@ const exchangeSchema = z.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
})
|
||||
|
||||
type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' }
|
||||
type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' | 'oidc_email_taken' }
|
||||
|
||||
// Compte relié à cette identité ; sinon le compte qui a cet email, si le fournisseur le garantit ; sinon un compte
|
||||
// neuf, seulement si l'instance en accepte (ALLOW_REGISTRATION, ou instance encore vide).
|
||||
@@ -64,11 +64,14 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
if (!identity.email) return { error: 'oidc_email_missing' }
|
||||
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true } })
|
||||
const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, passwordHash: true } })
|
||||
if (existing) {
|
||||
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
|
||||
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
|
||||
if (!identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
// Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce
|
||||
// compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers
|
||||
if (existing.passwordHash) return { error: 'oidc_email_taken' }
|
||||
|
||||
await app.prisma.userIdentity.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
|
||||
@@ -77,15 +77,22 @@ beforeEach(() => {
|
||||
prismaMock.$executeRaw.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
// Chaque connexion simulée vient de sa propre adresse : les routes OIDC sont limitées à 20 appels par minute et
|
||||
// par adresse, et le fichier en fait plus
|
||||
let visitor = 0
|
||||
const nextAddress = () => `10.0.${Math.floor(++visitor / 250)}.${visitor % 250 + 1}`
|
||||
let currentAddress = nextAddress()
|
||||
|
||||
// Début de connexion : renvoie le cookie signé que le navigateur rapportera au retour
|
||||
async function start(returnTo = '/') {
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}` })
|
||||
currentAddress = nextAddress()
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`, remoteAddress: currentAddress })
|
||||
const setCookie = String(res.headers['set-cookie'])
|
||||
return { res, cookie: setCookie.split(';')[0] }
|
||||
}
|
||||
|
||||
async function callback(cookie?: string, query = '?code=abc&state=state-1') {
|
||||
return app.inject({ method: 'GET', url: `/auth/oidc/callback${query}`, headers: cookie ? { cookie } : {} })
|
||||
return app.inject({ method: 'GET', url: `/auth/oidc/callback${query}`, headers: cookie ? { cookie } : {}, remoteAddress: currentAddress })
|
||||
}
|
||||
|
||||
function fragmentOf(location: string | undefined) {
|
||||
@@ -161,8 +168,17 @@ describe('compte relié, créé ou refusé', () => {
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('email vérifié qui a déjà un compte : relie le compte, ne le duplique pas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('email vérifié qui a déjà un compte sans mot de passe : relie le compte, ne le duplique pas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-7', issuer: ISSUER, subject: 'sub-1' } })
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('écran de connexion avec un fournisseur OIDC', () => {
|
||||
})
|
||||
|
||||
it('chaque refus au retour du fournisseur a sa phrase', () => {
|
||||
for (const code of ['oidc_failed', 'oidc_unavailable', 'oidc_email_unverified', 'oidc_email_missing', 'password_login_disabled', 'invalid_handoff']) {
|
||||
for (const code of ['oidc_failed', 'oidc_unavailable', 'oidc_email_unverified', 'oidc_email_taken', 'oidc_email_missing', 'password_login_disabled', 'invalid_handoff']) {
|
||||
const message = getAccountErrorMessage({ payload: { error: code } })
|
||||
expect(message).not.toBe("Erreur backend. Vérifiez que l'API et la base sont lancées.")
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
"Cette adresse n'est pas celle du compte.": "This isn't the account's email address.",
|
||||
"Votre adresse email, pour confirmer": "Your email address, to confirm",
|
||||
"Sur cette instance, la connexion passe par le bouton du fournisseur.": "On this instance, sign in with the provider button.",
|
||||
"Un compte avec mot de passe existe déjà pour cette adresse. Connectez-vous avec votre email et votre mot de passe.": "An account with a password already exists for this address. Sign in with your email and password.",
|
||||
"Votre fournisseur de connexion n'indique pas que votre adresse email est vérifiée. Vérifiez-la chez lui, puis réessayez.": "Your sign-in provider doesn't say your email address is verified. Verify it there, then try again.",
|
||||
"Votre fournisseur de connexion n'a pas donné d'adresse email. La personne qui héberge l'instance doit autoriser le partage de l'email.": "Your sign-in provider didn't share an email address. The person who hosts this instance needs to allow it.",
|
||||
"Le fournisseur de connexion ne répond pas. Réessayez dans un moment.": "The sign-in provider isn't responding. Try again in a moment.",
|
||||
|
||||
@@ -76,6 +76,10 @@ 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_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.")
|
||||
}
|
||||
|
||||
if (errorCode === 'oidc_email_missing') {
|
||||
return t("Votre fournisseur de connexion n'a pas donné d'adresse email. La personne qui héberge l'instance doit autoriser le partage de l'email.")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user