Compare commits

..

9 Commits

Author SHA1 Message Date
r-zakarya 9d74839ec3 fix(settings): la section Sync renommee retrouve son onglet
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:19:57 +01:00
r-zakarya df2d0f55b9 docs(sync): libelle 'Git repository (Gitea or GitHub)' au lieu de Gitea seul
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:16:03 +01:00
r-zakarya 67b726e08c fix(sync): la verification 'depot public' se fait sans jeton, avant tout le reste
Un jeton faux faisait repondre 401 a GitHub avant tout verdict : le refus
d'un depot public ne pouvait pas s'exprimer. Public = visible en anonyme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:04:50 +01:00
r-zakarya a2ac0e393b fix(sync): l'URL enregistree est convertie aussi a la lecture des reglages
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:00:10 +01:00
r-zakarya 329b0e3ce5 feat(sync): le PC accepte l'adresse du depot (github.com/O/R.git, Gitea) et la convertit en URL d'API
Garde : un dernier segment qui ressemble a un fichier (.json) reste une URL
WebDAV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:40:45 +01:00
r-zakarya 365ee2f596 docs(autofill): note l'idee de la case no-autofill dans l'editeur
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:31:11 +01:00
r-zakarya 994aaccac4 docs(autofill): etiquette no-autofill
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:29:37 +01:00
r-zakarya ab1da7e319 feat(autofill): etiquette no-autofill pour exclure une entree, copie du mot de passe toujours visible en vue tableau
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:22:14 +01:00
r-zakarya f1b7dd9bff docs(apk): note les idees 'identifiant puis mot de passe' et 'remplissage automatique Android'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:06:21 +01:00
9 changed files with 108 additions and 22 deletions
+16
View File
@@ -1352,6 +1352,22 @@ champ — ça détruisait le focus sur les forms qui n'étaient pas centrés
(hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser (hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser
title sans ambiguïté title sans ambiguïté
### Exclure une entrée : étiquette `no-autofill` (2026-09-18)
`AUTOFILL_SKIP_TAG` (js/app.autofill.js) retire l'entrée du pool **avant** le
scoring : raccourcis `Ctrl+Shift+L/P/T` et barrette l'ignorent. Une étiquette et
non une colonne : elle est déjà chiffrée, synchronisée, exportée et reprise par
la rotation — un champ de plus aurait demandé les 7 points de passage de
« Entry payload ». La recherche rapide `Ctrl+Shift+Q` garde ces entrées (choix
explicite, pas une correspondance devinée sur un titre).
**Idée gardée (rien n'est fait)** : une case « Ne pas proposer à l'autofill »
dans l'éditeur d'entrée (slideover), qui **ajoute ou retire la même étiquette**
— même stockage, mais plus besoin de connaître son nom exact. À brancher sur
`soDirtyCheck` (la case modifie les tags, donc l'entrée devient modifiée) et
sur la reconstruction des chips d'étiquettes pour que les deux restent
d'accord.
### Contraintes connues autofill ### Contraintes connues autofill
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin, - **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
+2 -1
View File
@@ -140,7 +140,8 @@ begin
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; LHttp.UserAgent := 'PMServer';
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; // SANS JETON, expres : "public" = visible par n'importe qui. Un jeton faux
// faisait repondre 401 avant tout verdict ; prive et anonyme = 404.
try try
LResp := LHttp.Get(RepoApiUrl(FBaseUrl)); LResp := LHttp.Get(RepoApiUrl(FBaseUrl));
except except
+18
View File
@@ -323,6 +323,24 @@ secret (`hasTotp`, un booléen — jamais le secret dans la page). Le code est
calculé à l'instant de la copie ; le message dit combien de secondes il lui calculé à l'instant de la copie ; le message dit combien de secondes il lui
reste. reste.
### Idées gardées pour plus tard (décidé le 2026-09-18)
**Rien n'est fait.** Deux propositions retenues pour copier encore plus vite :
- **Identifiant puis mot de passe, en un appui** (effort moyen). Un appui copie
l'identifiant ; au retour dans Vault après l'avoir collé (`BecameActive`), le
mot de passe de la même entrée est **déjà** dans le presse-papiers, avec un
message qui le dit. À borner : seulement si le retour a lieu dans la grâce de
2 minutes, et une seule fois — sinon un retour sans rapport recopierait un
mot de passe que personne n'a demandé.
- **Remplissage automatique Android** (gros chantier, plusieurs jours). Un
`AutofillService` comme Bitwarden : Android propose les comptes dans le champ
de connexion de n'importe quelle app ou de Chrome, sans copier-coller. Exige
une classe **Java** (Delphi ne sait pas l'écrire) compilée en `.jar` et
déclarée au manifeste avec `BIND_AUTOFILL_SERVICE`, et une authentification
(code, empreinte) **dans le service** avant de proposer quoi que ce soit : le
coffre ne vit que dans l'app, ouverte ou non.
## Saisie : ce qui a résisté ## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne - **`user-select: none` du `body`** était hérité par les champs : le curseur ne
+1
View File
@@ -2885,6 +2885,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
.col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; } .col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; }
.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); } .col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; } .entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
.col-actions .icon-btn.is-always { opacity: 0.7; }
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; } .col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }
/* Hide Edge/IE native password reveal eye (we ship our own toggle). */ /* Hide Edge/IE native password reveal eye (we ship our own toggle). */
+6 -1
View File
@@ -1659,7 +1659,12 @@ begin
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend // "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
// d'un jeton d'acces personnel, et l'autre donne un 401 muet. // d'un jeton d'acces personnel, et l'autre donne un 401 muet.
if LGitea then if LGitea then
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd begin
// Pas d'en-tete du tout sans jeton : la verification "depot
// public" se fait volontairement en anonyme.
if LPwd <> '' then
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd;
end
else if (LUser <> '') then else if (LUser <> '') then
begin begin
LHttp.CredentialsStorage.AddCredential( LHttp.CredentialsStorage.AddCredential(
+10 -9
View File
@@ -841,12 +841,12 @@
</div> </div>
<div class="slideover-field" id="syncField" style="display:none"> <div class="slideover-field" id="syncField" style="display:none">
<div class="slideover-field-label">Sync (WebDAV or Gitea)</div> <div class="slideover-field-label">Sync (WebDAV or Git repository)</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5"> <p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
Bidirectional sync with a WebDAV server (Nextcloud, Bidirectional sync with a WebDAV server (Nextcloud,
ownCloud, Apache mod_dav) <b>or a private Gitea ownCloud, Apache mod_dav) <b>or a private Git
repository</b> — paste a contents-API URL and it switches repository</b> (Gitea or GitHub — not GitLab) — paste the
transport on its own. Auto-merge by entry repository address and it switches transport on its own. Auto-merge by entry
timestamp. <b>Master password is never sent to the timestamp. <b>Master password is never sent to the
remote</b> — the snapshot is encrypted with a separate remote</b> — the snapshot is encrypted with a separate
sync password you set once per device (same on each). sync password you set once per device (same on each).
@@ -857,9 +857,9 @@
</label> </label>
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px"> <div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:6px"> <div style="margin-bottom:6px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — full path to the snapshot file, or a Gitea contents path</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — the repository address (github.com/you/repo.git or your Gitea), or a WebDAV file URL</div>
<input type="text" id="syncUrl" class="so-input" <input type="text" id="syncUrl" class="so-input"
placeholder="https://git.example.com/api/v1/repos/OWNER/REPO/contents/vault-sync.json" placeholder="https://github.com/you/repo.git"
style="width:100%;font-size:12px"> style="width:100%;font-size:12px">
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:6px"> <div style="display:flex;gap:8px;margin-bottom:6px">
@@ -873,9 +873,10 @@
</label> </label>
</div> </div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5"> <p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
On Gitea the username is ignored and the token needs On a Git repository the username is ignored and the
<b>write:repository</b> — a read-only one can pull but token needs write access (Gitea: <b>write:repository</b>,
never push. Each sync is a commit, and Git history is GitHub: <b>Contents: Read and write</b>) — a read-only
one can pull but never push. Each sync is a commit, and Git history is
kept: an old sync password stays attackable in earlier kept: an old sync password stays attackable in earlier
versions, so prune the history if you rotate it. versions, so prune the history if you rotate it.
</p> </p>
+10 -3
View File
@@ -147,6 +147,8 @@ function autofillPushHotkeys(silent) {
const BROWSER_SUFFIX_RE = const BROWSER_SUFFIX_RE =
/\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i; /\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i;
const AUTOFILL_SKIP_TAG = 'no-autofill';
function autofillStripBrowserSuffix(title) { function autofillStripBrowserSuffix(title) {
return (title || '').replace(BROWSER_SUFFIX_RE, '').trim(); return (title || '').replace(BROWSER_SUFFIX_RE, '').trim();
} }
@@ -274,9 +276,14 @@ async function autofillHandleRequest(windowTitle, kind) {
// The 2FA hotkey can only serve entries that actually carry a TOTP secret // The 2FA hotkey can only serve entries that actually carry a TOTP secret
// — filtering here (not after scoring) also means the picker never lists a // — filtering here (not after scoring) also means the picker never lists a
// candidate that would fail once chosen. // candidate that would fail once chosen.
const pool = (kind === 'totp') // EXCLUE DE L'AUTOFILL par l'etiquette reservee AUTOFILL_SKIP_TAG : une
? state.entries.filter(e => e.totp_secret && e.totp_iv) // etiquette et non un champ, car elle voyage deja partout (chiffree, synchro,
: state.entries; // export, rotation) sans une colonne de plus, et un clic dessus liste toutes
// les entrees exclues. La recherche rapide (Ctrl+Shift+Q) les garde : c'est
// un choix explicite, pas une correspondance devinee sur un titre.
const pool = state.entries
.filter(e => !parseTags(e.tags).some(t => t.toLowerCase() === AUTOFILL_SKIP_TAG))
.filter(e => kind !== 'totp' || (e.totp_secret && e.totp_iv));
const scored = pool const scored = pool
.map(e => ({ entry: e, score: autofillScore(e, titleLower) })) .map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
.filter(x => x.score > 0) .filter(x => x.score > 0)
+15 -4
View File
@@ -3606,8 +3606,10 @@ function renderTableRow(e) {
break; break;
case 'actions': { case 'actions': {
td = el('td', { class: 'col-actions' }); td = el('td', { class: 'col-actions' });
// TOUJOURS VISIBLE, contrairement aux autres actions de la
// ligne : c'est le geste le plus frequent de la vue tableau.
const pwBtn = el('button', { const pwBtn = el('button', {
class: 'icon-btn icon-btn-sm', class: 'icon-btn icon-btn-sm is-always',
title: e.kind === 'note' ? 'Copy note content' : 'Copy password', title: e.kind === 'note' ? 'Copy note content' : 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
}); });
@@ -6859,7 +6861,7 @@ const SETTINGS_TAB_OF = {
'Fill bar': 'fillbar', 'Fill bar': 'fillbar',
'Account': 'account', 'Account': 'account',
'PIN unlock': 'account', 'Quick unlock': 'account', 'Recovery key': 'account', 'PIN unlock': 'account', 'Quick unlock': 'account', 'Recovery key': 'account',
'Export': 'sync', 'Sync (WebDAV or Gitea)': 'sync', 'Export': 'sync', 'Sync (WebDAV or Git repository)': 'sync',
'Auto-backup': 'sync', 'Import': 'sync', 'Auto-backup': 'sync', 'Import': 'sync',
}; };
let settingsActiveTab = 'general'; let settingsActiveTab = 'general';
@@ -8219,8 +8221,17 @@ async function init() {
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none'; if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
toast(on ? 'Sync enabled' : 'Sync disabled'); toast(on ? 'Sync enabled' : 'Sync disabled');
}); });
if (syncUrlEl) syncUrlEl.addEventListener('change', e => // L'adresse du depot suffit (github.com/O/R.git, Gitea) : elle est
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim())); // convertie en URL d'API, et le champ montre ce qui est enregistre.
if (syncUrlEl) syncUrlEl.addEventListener('change', e => {
const raw = e.target.value.trim();
const url = syncNormalizeRepoUrl(raw);
if (url !== raw) {
e.target.value = url;
toast('Repository address converted to its API URL');
}
Bridge.setPref(SYNC_PREFS.url, url);
});
if (syncUserEl) syncUserEl.addEventListener('change', e => if (syncUserEl) syncUserEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim())); Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
if (syncPwdEl) syncPwdEl.addEventListener('change', e => if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
+30 -4
View File
@@ -90,13 +90,36 @@ function syncIsGitea(url) {
return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/'); return u.includes('/api/v1/repos/') || u.includes('api.github.com/repos/');
} }
// ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE.
// https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API
// de Gitea. Fichier par defaut vault-sync.json, ou celui d'un lien .../blob/B/F
// (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une
// URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a
// l'air d'un fichier (.json...) n'est pas un depot - sinon
// https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire.
function syncNormalizeRepoUrl(u) {
u = (u || '').replace(/\s+/g, '');
if (!u || syncIsGitea(u)) return u;
const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
'/contents/' + (g[4] || 'vault-sync.json');
const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
if (!m) return u;
if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || 'vault-sync.json');
}
// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde // UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde
// tous les instantanes, chacun attaquable hors ligne par qui peut les lire. // tous les instantanes, chacun attaquable hors ligne par qui peut les lire.
// '' = prive ou impossible a savoir (on ne bloque pas sur un doute reseau). // '' = prive ou impossible a savoir (on ne bloque pas sur un doute reseau).
async function syncRepoPublicWarning(cfg) { async function syncRepoPublicWarning(cfg) {
if (!syncIsGitea(cfg.url)) return ''; if (!syncIsGitea(cfg.url)) return '';
const repoUrl = cfg.url.split('/contents/')[0]; const repoUrl = cfg.url.split('/contents/')[0];
const r = await _webdavCall('get', repoUrl, cfg.user, cfg.pwd); // SANS JETON, expres : "public" veut dire "visible par n'importe qui", et
// c'est exactement la question posee. Avec un jeton faux, GitHub repondait
// 401 avant de dire quoi que ce soit du depot ; un depot prive repond 404
// sans jeton, ce qui veut dire "pas public".
const r = await _webdavCall('get', repoUrl, '', '');
if (r.status < 200 || r.status >= 300 || !r.payload) return ''; if (r.status < 200 || r.status >= 300 || !r.payload) return '';
try { try {
const o = JSON.parse(new TextDecoder().decode(base64ToBytes(r.payload))); const o = JSON.parse(new TextDecoder().decode(base64ToBytes(r.payload)));
@@ -273,7 +296,9 @@ async function loadSyncConfig(withSecrets) {
]); ]);
return { return {
enabled: enabled === '1', enabled: enabled === '1',
url: url || '', // Convertie aussi a la LECTURE : une adresse de depot enregistree par un
// exe anterieur (ou tapee avant la conversion) ne l'avait jamais ete.
url: syncNormalizeRepoUrl(url || ''),
user: user || '', user: user || '',
pwd: pwd || '', pwd: pwd || '',
encPwd: encPwd || '', encPwd: encPwd || '',
@@ -288,6 +313,9 @@ async function syncTestConnection() {
const cfg = await loadSyncConfig(true); // needs the WebDAV credentials const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning'); if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
toast('Testing connection…'); toast('Testing connection…');
// LE DEPOT PUBLIC D'ABORD : il est refuse quel que soit le jeton.
const pubFirst = await syncRepoPublicWarning(cfg);
if (pubFirst) return toast(pubFirst, 'error');
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd); const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
// A reachable Gitea with a valid token is not enough: a URL pointing one // A reachable Gitea with a valid token is not enough: a URL pointing one
// path segment off answers 200 with JSON that is not a file. Checking the // path segment off answers 200 with JSON that is not a file. Checking the
@@ -297,8 +325,6 @@ async function syncTestConnection() {
try { try {
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || ''))); un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
} catch (e) { /* traite comme une forme inattendue */ } } catch (e) { /* traite comme une forme inattendue */ }
const pub = await syncRepoPublicWarning(cfg);
if (pub) return toast(pub, 'error');
return un return un
? toast('Connection OK · file found in the repository') ? toast('Connection OK · file found in the repository')
: toast('Reached Gitea, but this URL does not return a file — ' + : toast('Reached Gitea, but this URL does not return a file — ' +