Feature: add Google Drive backup and restore integration
This commit is contained in:
@@ -12,3 +12,10 @@ VG_DB_PASSWORD=change_me
|
||||
# Auth de l'interface web
|
||||
APP_BASIC_AUTH_USER=beuz
|
||||
APP_BASIC_AUTH_PASSWORD=change_me_now
|
||||
|
||||
# Google Drive OAuth
|
||||
PUBLIC_BASE_URL=http://localhost:7001
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=
|
||||
GOOGLE_DRIVE_FOLDER_NAME=BeuzGamesBackups
|
||||
|
||||
14
README.md
14
README.md
@@ -71,6 +71,13 @@ API_PORT=7002
|
||||
VG_DB_NAME=video_games
|
||||
VG_DB_USER=video_games_user
|
||||
VG_DB_PASSWORD=change_me
|
||||
APP_BASIC_AUTH_USER=beuz
|
||||
APP_BASIC_AUTH_PASSWORD=change_me_now
|
||||
PUBLIC_BASE_URL=http://localhost:7001
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=
|
||||
GOOGLE_DRIVE_FOLDER_NAME=BeuzGamesBackups
|
||||
```
|
||||
|
||||
Tu peux mettre `7000` si ce port est libre sur ta machine.
|
||||
@@ -149,6 +156,13 @@ git pull
|
||||
- POST `/api/backup/restore` (modes `merge` ou `replace`)
|
||||
- snapshot auto en base avant restore `replace` (`backup_snapshots`)
|
||||
- actions accessibles dans le panneau lateral `Outils`
|
||||
- Etape 7: sauvegarde/restauration Google Drive (OAuth)
|
||||
- GET `/api/google/status`
|
||||
- GET `/api/google/connect-url`
|
||||
- GET `/auth/google/callback`
|
||||
- POST `/api/google/backup/upload`
|
||||
- GET `/api/google/backups`
|
||||
- POST `/api/google/backup/restore`
|
||||
|
||||
## Import Excel (COLLECTIONS.xlsx)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"googleapis": "^150.0.1",
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
316
api/server.js
316
api/server.js
@@ -1,15 +1,23 @@
|
||||
const http = require("http");
|
||||
const crypto = require("crypto");
|
||||
const { Pool } = require("pg");
|
||||
const { google } = require("googleapis");
|
||||
|
||||
const port = Number(process.env.API_INTERNAL_PORT || 3001);
|
||||
const serviceName = process.env.SERVICE_NAME || "video-games-api";
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://video_games_user:change_me@video-games-db:5432/video_games";
|
||||
const publicBaseUrl = process.env.PUBLIC_BASE_URL || "http://localhost:7001";
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID || "";
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET || "";
|
||||
const googleRedirectUri = process.env.GOOGLE_REDIRECT_URI || "";
|
||||
const googleDriveFolderName = process.env.GOOGLE_DRIVE_FOLDER_NAME || "BeuzGamesBackups";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
const oauthStateCache = new Map();
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
||||
@@ -129,6 +137,18 @@ async function runMigrations() {
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS google_drive_auth (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL UNIQUE,
|
||||
refresh_token TEXT,
|
||||
access_token TEXT,
|
||||
expiry_date BIGINT,
|
||||
scope TEXT,
|
||||
email TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
@@ -804,9 +824,254 @@ async function importCatalog(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function isGoogleConfigured() {
|
||||
return Boolean(googleClientId && googleClientSecret && googleRedirectUri);
|
||||
}
|
||||
|
||||
function buildGoogleOAuthClient() {
|
||||
if (!isGoogleConfigured()) {
|
||||
throw new Error("Google OAuth is not configured");
|
||||
}
|
||||
return new google.auth.OAuth2(googleClientId, googleClientSecret, googleRedirectUri);
|
||||
}
|
||||
|
||||
async function getGoogleAuthRow() {
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM google_drive_auth WHERE provider = 'google' ORDER BY id ASC LIMIT 1;",
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function upsertGoogleAuthRow(data) {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO google_drive_auth(provider, refresh_token, access_token, expiry_date, scope, email, updated_at)
|
||||
VALUES ('google', $1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (provider)
|
||||
DO UPDATE SET
|
||||
refresh_token = COALESCE(EXCLUDED.refresh_token, google_drive_auth.refresh_token),
|
||||
access_token = EXCLUDED.access_token,
|
||||
expiry_date = EXCLUDED.expiry_date,
|
||||
scope = EXCLUDED.scope,
|
||||
email = EXCLUDED.email,
|
||||
updated_at = NOW();
|
||||
`,
|
||||
[
|
||||
data.refreshToken || null,
|
||||
data.accessToken || null,
|
||||
data.expiryDate || null,
|
||||
data.scope || null,
|
||||
data.email || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function getScopes() {
|
||||
return [
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"openid",
|
||||
];
|
||||
}
|
||||
|
||||
async function buildGoogleAuthUrl() {
|
||||
const oauth2Client = buildGoogleOAuthClient();
|
||||
const state = crypto.randomBytes(24).toString("hex");
|
||||
oauthStateCache.set(state, Date.now() + 10 * 60 * 1000);
|
||||
|
||||
const url = oauth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
scope: getScopes(),
|
||||
state,
|
||||
});
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function cleanupExpiredStates() {
|
||||
const now = Date.now();
|
||||
for (const [state, expiresAt] of oauthStateCache.entries()) {
|
||||
if (expiresAt < now) {
|
||||
oauthStateCache.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleCallback(urlObj) {
|
||||
cleanupExpiredStates();
|
||||
|
||||
const code = urlObj.searchParams.get("code");
|
||||
const state = urlObj.searchParams.get("state");
|
||||
if (!code || !state || !oauthStateCache.has(state)) {
|
||||
throw new Error("Invalid OAuth callback state");
|
||||
}
|
||||
oauthStateCache.delete(state);
|
||||
|
||||
const oauth2Client = buildGoogleOAuthClient();
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
oauth2Client.setCredentials(tokens);
|
||||
|
||||
const oauth2 = google.oauth2({ auth: oauth2Client, version: "v2" });
|
||||
const me = await oauth2.userinfo.get();
|
||||
const email = (me.data && me.data.email) || "";
|
||||
|
||||
await upsertGoogleAuthRow({
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
accessToken: tokens.access_token || null,
|
||||
expiryDate: tokens.expiry_date || null,
|
||||
scope: tokens.scope || "",
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async function getGoogleStatus() {
|
||||
if (!isGoogleConfigured()) {
|
||||
return { configured: false, connected: false };
|
||||
}
|
||||
|
||||
const row = await getGoogleAuthRow();
|
||||
const connected = Boolean(row && row.refresh_token);
|
||||
return {
|
||||
configured: true,
|
||||
connected,
|
||||
email: connected ? row.email || "" : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function getGoogleDriveClient() {
|
||||
const status = await getGoogleStatus();
|
||||
if (!status.configured) {
|
||||
throw new Error("Google OAuth is not configured");
|
||||
}
|
||||
if (!status.connected) {
|
||||
throw new Error("Google account is not connected");
|
||||
}
|
||||
|
||||
const row = await getGoogleAuthRow();
|
||||
const oauth2Client = buildGoogleOAuthClient();
|
||||
oauth2Client.setCredentials({
|
||||
refresh_token: row.refresh_token,
|
||||
access_token: row.access_token || undefined,
|
||||
expiry_date: row.expiry_date || undefined,
|
||||
});
|
||||
|
||||
return google.drive({ version: "v3", auth: oauth2Client });
|
||||
}
|
||||
|
||||
async function ensureDriveFolder(drive) {
|
||||
const query = [
|
||||
"mimeType='application/vnd.google-apps.folder'",
|
||||
`name='${googleDriveFolderName.replace(/'/g, "\\'")}'`,
|
||||
"trashed=false",
|
||||
].join(" and ");
|
||||
|
||||
const found = await drive.files.list({
|
||||
q: query,
|
||||
fields: "files(id,name)",
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
if (found.data.files && found.data.files.length) {
|
||||
return found.data.files[0].id;
|
||||
}
|
||||
|
||||
const created = await drive.files.create({
|
||||
requestBody: {
|
||||
name: googleDriveFolderName,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
},
|
||||
fields: "id,name",
|
||||
});
|
||||
|
||||
return created.data.id;
|
||||
}
|
||||
|
||||
async function uploadBackupToGoogleDrive() {
|
||||
const drive = await getGoogleDriveClient();
|
||||
const dump = await exportCatalogDump();
|
||||
const folderId = await ensureDriveFolder(drive);
|
||||
const fileName = `video-games-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
|
||||
const created = await drive.files.create({
|
||||
requestBody: {
|
||||
name: fileName,
|
||||
parents: [folderId],
|
||||
mimeType: "application/json",
|
||||
},
|
||||
media: {
|
||||
mimeType: "application/json",
|
||||
body: JSON.stringify(dump, null, 2),
|
||||
},
|
||||
fields: "id,name,createdTime",
|
||||
});
|
||||
|
||||
return {
|
||||
fileId: created.data.id,
|
||||
fileName: created.data.name,
|
||||
createdTime: created.data.createdTime,
|
||||
};
|
||||
}
|
||||
|
||||
async function listGoogleBackups() {
|
||||
const drive = await getGoogleDriveClient();
|
||||
const folderId = await ensureDriveFolder(drive);
|
||||
const query = [
|
||||
`'${folderId}' in parents`,
|
||||
"mimeType='application/json'",
|
||||
"trashed=false",
|
||||
].join(" and ");
|
||||
|
||||
const found = await drive.files.list({
|
||||
q: query,
|
||||
pageSize: 50,
|
||||
orderBy: "createdTime desc",
|
||||
fields: "files(id,name,createdTime,size)",
|
||||
});
|
||||
|
||||
return found.data.files || [];
|
||||
}
|
||||
|
||||
async function restoreFromGoogleBackup(fileId, mode) {
|
||||
const drive = await getGoogleDriveClient();
|
||||
|
||||
let targetId = fileId || "";
|
||||
if (!targetId) {
|
||||
const files = await listGoogleBackups();
|
||||
if (!files.length) {
|
||||
throw new Error("No Google Drive backup found");
|
||||
}
|
||||
targetId = files[0].id;
|
||||
}
|
||||
|
||||
const file = await drive.files.get(
|
||||
{
|
||||
fileId: targetId,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "json" },
|
||||
);
|
||||
|
||||
const dump = file.data;
|
||||
const result = await restoreCatalogDump(mode || "merge", dump);
|
||||
return { fileId: targetId, ...result };
|
||||
}
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/auth/google/callback") {
|
||||
try {
|
||||
await handleGoogleCallback(url);
|
||||
response.writeHead(302, { Location: `${publicBaseUrl}/?google=connected` });
|
||||
response.end();
|
||||
} catch (error) {
|
||||
response.writeHead(302, { Location: `${publicBaseUrl}/?google=error` });
|
||||
response.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
try {
|
||||
await pool.query("SELECT 1;");
|
||||
@@ -900,6 +1165,57 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/google/status") {
|
||||
try {
|
||||
const status = await getGoogleStatus();
|
||||
sendJson(response, 200, status);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/google/connect-url") {
|
||||
try {
|
||||
const connectUrl = await buildGoogleAuthUrl();
|
||||
sendJson(response, 200, { url: connectUrl });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/google/backup/upload") {
|
||||
try {
|
||||
const result = await uploadBackupToGoogleDrive();
|
||||
sendJson(response, 200, { status: "ok", ...result });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/google/backups") {
|
||||
try {
|
||||
const files = await listGoogleBackups();
|
||||
sendJson(response, 200, { files });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/google/backup/restore") {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const result = await restoreFromGoogleBackup(body.fileId || "", body.mode || "merge");
|
||||
sendJson(response, 200, { status: "ok", ...result });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/backup/restore") {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
|
||||
105
app.js
105
app.js
@@ -46,6 +46,10 @@ const backupBtn = document.getElementById("backupBtn");
|
||||
const restoreMergeBtn = document.getElementById("restoreMergeBtn");
|
||||
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
|
||||
const restoreFileInput = document.getElementById("restoreFileInput");
|
||||
const googleDriveState = document.getElementById("googleDriveState");
|
||||
const googleConnectBtn = document.getElementById("googleConnectBtn");
|
||||
const googleBackupBtn = document.getElementById("googleBackupBtn");
|
||||
const googleRestoreBtn = document.getElementById("googleRestoreBtn");
|
||||
const quickSearchInput = document.getElementById("quickSearchInput");
|
||||
const quickSearchResults = document.getElementById("quickSearchResults");
|
||||
const gamesList = document.getElementById("gamesList");
|
||||
@@ -53,6 +57,7 @@ const gameCardTemplate = document.getElementById("gameCardTemplate");
|
||||
let editingGameId = null;
|
||||
let pendingRestoreMode = "merge";
|
||||
let quickSearchTerm = "";
|
||||
let googleStatus = { configured: false, connected: false, email: "" };
|
||||
|
||||
toolsToggleBtn.addEventListener("click", () => {
|
||||
toolsDrawer.classList.toggle("open");
|
||||
@@ -81,6 +86,48 @@ document.addEventListener("keydown", (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
googleConnectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = await apiRequest("/api/google/connect-url");
|
||||
if (!payload.url) {
|
||||
throw new Error("Missing Google connect URL");
|
||||
}
|
||||
window.location.href = payload.url;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Connexion Google indisponible. Verifie la configuration OAuth.");
|
||||
}
|
||||
});
|
||||
|
||||
googleBackupBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = await apiRequest("/api/google/backup/upload", { method: "POST" });
|
||||
await refreshGoogleStatus();
|
||||
alert(`Sauvegarde Drive OK: ${payload.fileName || "fichier cree"}.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Echec sauvegarde Google Drive.");
|
||||
}
|
||||
});
|
||||
|
||||
googleRestoreBtn.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("Restaurer depuis le dernier backup Google Drive ?");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await apiRequest("/api/google/backup/restore", {
|
||||
method: "POST",
|
||||
body: { mode: "merge" },
|
||||
});
|
||||
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
||||
alert(`Restauration Drive OK (${payload.mode})`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Echec restauration Google Drive.");
|
||||
}
|
||||
});
|
||||
|
||||
quickSearchInput.addEventListener("input", (event) => {
|
||||
quickSearchTerm = event.target.value.trim();
|
||||
renderSearchResults();
|
||||
@@ -435,6 +482,7 @@ restoreFileInput.addEventListener("change", async (event) => {
|
||||
|
||||
function render() {
|
||||
renderDataMode();
|
||||
renderGoogleStatus();
|
||||
renderBrandTabs();
|
||||
renderConsoleTabs();
|
||||
renderGames();
|
||||
@@ -491,6 +539,34 @@ function renderDataMode() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderGoogleStatus() {
|
||||
if (!googleDriveState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!googleStatus.configured) {
|
||||
googleDriveState.textContent = "Etat Google Drive: non configure (OAuth manquant).";
|
||||
googleConnectBtn.disabled = true;
|
||||
googleBackupBtn.disabled = true;
|
||||
googleRestoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!googleStatus.connected) {
|
||||
googleDriveState.textContent = "Etat Google Drive: non connecte.";
|
||||
googleConnectBtn.disabled = false;
|
||||
googleBackupBtn.disabled = true;
|
||||
googleRestoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const email = googleStatus.email ? ` (${googleStatus.email})` : "";
|
||||
googleDriveState.textContent = `Etat Google Drive: connecte${email}.`;
|
||||
googleConnectBtn.disabled = false;
|
||||
googleBackupBtn.disabled = false;
|
||||
googleRestoreBtn.disabled = false;
|
||||
}
|
||||
|
||||
function findBrandByConsole(consoleName) {
|
||||
for (const [brand, consoles] of Object.entries(state.brands)) {
|
||||
if (Array.isArray(consoles) && consoles.includes(consoleName)) {
|
||||
@@ -829,6 +905,15 @@ async function refreshFromApi(preferredBrand, preferredConsole) {
|
||||
render();
|
||||
}
|
||||
|
||||
async function refreshGoogleStatus() {
|
||||
try {
|
||||
googleStatus = await apiRequest("/api/google/status");
|
||||
} catch {
|
||||
googleStatus = { configured: false, connected: false, email: "" };
|
||||
}
|
||||
renderGoogleStatus();
|
||||
}
|
||||
|
||||
async function hydrateFromApi() {
|
||||
try {
|
||||
await refreshFromApi(state.selectedBrand, state.selectedConsole);
|
||||
@@ -839,9 +924,29 @@ async function hydrateFromApi() {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await refreshGoogleStatus();
|
||||
await hydrateFromApi();
|
||||
normalizeState();
|
||||
render();
|
||||
handleGoogleCallbackResult();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
function handleGoogleCallbackResult() {
|
||||
const url = new URL(window.location.href);
|
||||
const googleParam = url.searchParams.get("google");
|
||||
if (!googleParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (googleParam === "connected") {
|
||||
alert("Google Drive connecte avec succes.");
|
||||
refreshGoogleStatus();
|
||||
} else if (googleParam === "error") {
|
||||
alert("Connexion Google Drive echouee.");
|
||||
}
|
||||
|
||||
url.searchParams.delete("google");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ services:
|
||||
- SERVICE_NAME=video-games-api
|
||||
- API_INTERNAL_PORT=3001
|
||||
- DATABASE_URL=postgres://${VG_DB_USER:-video_games_user}:${VG_DB_PASSWORD:-change_me}@video-games-db:5432/${VG_DB_NAME:-video_games}
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:7001}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI:-}
|
||||
- GOOGLE_DRIVE_FOLDER_NAME=${GOOGLE_DRIVE_FOLDER_NAME:-BeuzGamesBackups}
|
||||
ports:
|
||||
- "${API_PORT:-7002}:3001"
|
||||
|
||||
|
||||
10
index.html
10
index.html
@@ -30,6 +30,16 @@
|
||||
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
|
||||
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
|
||||
</div>
|
||||
|
||||
<div class="google-section">
|
||||
<h3 class="google-title">☁ Google Drive Backup</h3>
|
||||
<p id="googleDriveState" class="google-state">Etat Google Drive: detection...</p>
|
||||
<div class="backup-controls">
|
||||
<button id="googleConnectBtn" type="button" class="btn-secondary">Connecter Google</button>
|
||||
<button id="googleBackupBtn" type="button" class="btn-secondary">Sauvegarder sur Drive</button>
|
||||
<button id="googleRestoreBtn" type="button" class="btn-secondary">Restaurer depuis Drive</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="app-shell">
|
||||
|
||||
@@ -16,6 +16,16 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /auth/google/callback {
|
||||
auth_basic off;
|
||||
proxy_pass http://video-games-api:3001/auth/google/callback;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
auth_basic off;
|
||||
proxy_pass http://video-games-api:3001/health;
|
||||
|
||||
16
styles.css
16
styles.css
@@ -86,6 +86,22 @@ body {
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.google-section {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
.google-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.google-state {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1100px, 94vw);
|
||||
margin: 2rem auto;
|
||||
|
||||
Reference in New Issue
Block a user