feat(auth): connexion avec un compte Google (find-or-create, import likes/abos inclus)

- Backend: GET /api/auth/google/{url,callback} (state login distinct du
  link import), echange code, profil userinfo, find-or-create (OAuth sub,
  puis email), session + cookies comme login interne, connexion stockee
  dans oauth_connections pour l'import direct
- Front: bouton 'Se connecter avec Google' sur /auth/login (visible si
  configure), page /auth/google/callback (initSession + prefs + redirect
  /library/import), cles i18n nav.import deja en place
- Comptes Google-only: password_hash aleatoire (login mot de passe impossible)
This commit is contained in:
2026-09-26 21:02:12 -04:00
parent ecb4295a59
commit 783003822d
9 changed files with 252 additions and 2 deletions
@@ -0,0 +1,9 @@
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8 text-center">
<div *ngIf="!error()" class="text-slate-300">Connexion avec Google…</div>
<div *ngIf="error() as e" class="space-y-4">
<p class="rounded border border-red-600 bg-red-900/50 px-3 py-2 text-sm text-red-200">{{ e }}</p>
<a routerLink="/auth/login" class="inline-block rounded bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500">Retour à la connexion</a>
</div>
</div>
</div>
@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../../../services/auth.service';
import { UserService } from '../../../services/user.service';
import { InstanceService } from '../../../services/instance.service';
/** Retour du login Google : la session est déjà posée en cookies par le backend. */
@Component({
selector: 'app-google-callback-page',
standalone: true,
templateUrl: './google-callback.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink],
})
export class GoogleCallbackComponent implements OnInit {
private auth = inject(AuthService);
private users = inject(UserService);
private instances = inject(InstanceService);
private route = inject(ActivatedRoute);
private router = inject(Router);
error = signal<string | null>(null);
async ngOnInit(): Promise<void> {
const err = this.route.snapshot.queryParamMap.get('error');
if (err) {
this.error.set(`Google : ${err}`);
return;
}
try {
const ok = await firstValueFrom(this.auth.initSession());
if (!ok) {
this.error.set('Session Google introuvable. Réessayez.');
return;
}
try {
await firstValueFrom(this.auth.fetchMe());
} catch {}
try {
const prefs = await firstValueFrom(this.users.loadPreferences());
if (prefs) {
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
if (prefs.region) this.instances.setRegion(prefs.region);
try { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {}
}
} catch {}
this.router.navigate(['/library/import'], { queryParams: { provider: 'google', connected: 1 } });
} catch {
this.error.set('Connexion Google impossible. Réessayez.');
}
}
}
@@ -24,6 +24,18 @@
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Sign in</button>
</form>
<div *ngIf="googleAvailable()" class="mt-4">
<div class="mb-3 flex items-center gap-3 text-xs text-slate-500">
<span class="h-px flex-1 bg-slate-700"></span><span>ou</span><span class="h-px flex-1 bg-slate-700"></span>
</div>
<button type="button" [disabled]="googleBusy()" (click)="loginWithGoogle()"
class="flex w-full items-center justify-center gap-2 rounded border border-slate-600 bg-white px-3 py-2 font-medium text-slate-800 transition hover:bg-slate-100 disabled:opacity-60">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.3-2.3H12v4.5h6.5c-.1 1.1-.8 2.7-2.4 3.8l-.1.4 3.5 2.7.2.1c2.2-2 3.6-5 3.6-9.2z"/><path fill="#34A853" d="M12 24c3.2 0 5.9-1.1 7.9-2.9l-3.8-2.9c-1 .7-2.4 1.2-4.1 1.2-3.1 0-5.8-2.1-6.8-5l-.4.1-3.6 2.8v.3C3.3 21.3 7.3 24 12 24z"/><path fill="#FBBC05" d="M5.2 14.4c-.2-.7-.4-1.5-.4-2.4s.1-1.7.4-2.4l-.1-.4-3.6-2.8-.1.1C.5 8.3 0 10 0 12s.5 3.7 1.4 5.3l3.8-2.9z"/><path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17.9 1 15.2 0 12 0 7.3 0 3.3 2.7 1.4 6.7l3.8 2.9c1-2.9 3.7-5 6.8-5z"/></svg>
{{ googleBusy() ? 'Redirection…' : 'Se connecter avec Google' }}
</button>
<p class="mt-2 text-xs text-slate-500">Crée ou lie votre compte NewTube. Les abonnements et favoris YouTube deviennent importables depuis Bibliothèque › Importer.</p>
</div>
<div class="mt-4 text-sm text-slate-300">
Don't have an account?
<a routerLink="/auth/register" class="text-red-400 hover:text-red-300">Create one</a>
@@ -25,6 +25,32 @@ export class LoginComponent {
remember = signal(true);
error = signal<string | null>(null);
busy = signal(false);
googleAvailable = signal(false);
googleBusy = signal(false);
constructor() {
// Bouton Google visible seulement si OAuth configuré côté serveur.
try {
this.auth.googleAuthUrlAvailable().subscribe({ next: (ok) => this.googleAvailable.set(ok) });
} catch {}
// Erreur OAuth renvoyée par le callback (redirect /auth/login?error=...).
try {
const params = new URLSearchParams(window.location.search);
const oauthError = params.get('error');
if (oauthError) this.error.set(`Google : ${oauthError}`);
} catch {}
}
loginWithGoogle(): void {
this.error.set(null);
this.googleBusy.set(true);
this.auth.redirectToGoogle().subscribe({
error: (e: any) => {
this.error.set(e?.error?.error || e?.message || 'Google login failed.');
this.googleBusy.set(false);
},
});
}
async submit() {
this.error.set(null);