feat: client auth — login/password flow, auto device reg, sync interval + improved sync UI

This commit is contained in:
mirivlad 2026-06-01 23:36:19 +08:00
parent 241a9d8c06
commit f8dc436709
4 changed files with 129 additions and 16 deletions

View File

@ -846,6 +846,7 @@ type SyncStatusDTO struct {
DeviceID string `json:"deviceId"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
@ -863,11 +864,23 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval
}
return dto, nil
}
func (a *App) SyncConfigure(serverURL, apiKey string) error {
func (a *App) SyncConfigure(serverURL, username, password string) error {
// Register device on server with user credentials.
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
if err != nil {
return fmt.Errorf("register: %w", err)
}
if err := a.sync.SetState(serverURL, apiKey); err != nil {
return err
}
@ -878,6 +891,22 @@ func (a *App) SyncConfigure(serverURL, apiKey string) error {
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.APIKey = apiKey
cfg.Sync.DeviceID = deviceID
return config.Save(a.vault, cfg)
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
return err
}
func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault)
if err != nil {
return err
}
cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
}

View File

@ -103,7 +103,9 @@
let syncStatus = null
let syncLoading = false
let syncServerUrl = ''
let syncApiKey = ''
let syncUsername = ''
let syncPassword = ''
let syncInterval = 0
let syncResult = ''
const tabs = [
@ -897,14 +899,16 @@
try {
syncStatus = await wailsCall('SyncStatus')
} catch (e) {
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '' }
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
}
}
function openSettings() {
showSettings = true
syncServerUrl = syncStatus?.serverUrl || ''
syncApiKey = ''
syncUsername = ''
syncPassword = ''
syncInterval = syncStatus?.syncInterval || 0
syncResult = ''
}
@ -917,7 +921,10 @@
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncConfigure', syncServerUrl, syncApiKey)
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
if (syncInterval > 0) {
await wailsCall('SyncSetInterval', syncInterval)
}
syncResult = 'ok'
await loadSyncStatus()
} catch (e) {
@ -926,6 +933,18 @@
syncLoading = false
}
async function testConnection() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
syncResult = 'connection ok'
} catch (e) {
syncResult = 'connection failed: ' + String(e)
}
syncLoading = false
}
async function runSyncNow() {
syncLoading = true
syncResult = ''
@ -971,10 +990,12 @@
{/if}
</nav>
<div class="sidebar-footer">
<span class="version">{version}</span>
<button class="settings-btn" on:click={openSettings} title="Настройки">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="sync-dot" class:active={syncStatus?.configured}></span>
<span class="sidebar-sync-label">Синхронизация</span>
</button>
<span class="version">{version}</span>
</div>
</aside>
@ -991,6 +1012,16 @@
<span class="crumb placeholder">Выберите раздел или дело</span>
{/if}
</div>
<div class="header-right">
{#if syncStatus?.configured}
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{#if syncStatus.unpushedOps > 0}
<span class="sync-badge">{syncStatus.unpushedOps}</span>
{/if}
</button>
{/if}
</div>
</header>
{#if error}
@ -1502,17 +1533,26 @@
{/if}
<div class="form-group">
<label>URL сервера</label>
<input type="text" placeholder="https://example.com:8443" bind:value={syncServerUrl} />
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
</div>
<div class="form-group">
<label>API-ключ</label>
<input type="password" placeholder="ключ из админ-панели" bind:value={syncApiKey} />
<label>Логин</label>
<input type="text" placeholder="username" bind:value={syncUsername} />
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" placeholder="password" bind:value={syncPassword} />
</div>
<div class="form-group">
<label>Автосинхронизация (мин)</label>
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" />
</div>
{#if syncResult}
<div class="sync-result">{syncResult}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Сохранить</button>
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
<button class="btn" on:click={closeSettings}>Закрыть</button>
</div>
@ -1538,12 +1578,18 @@
.nav-item:hover { background: #222233; }
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
.version { font-size: 11px; color: #555; }
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.version { font-size: 11px; color: #555; text-align: center; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
.header-right { display: flex; align-items: center; gap: 8px; }
.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
@ -1732,8 +1778,11 @@
.activity-feed-time { font-size: 11px; color: #555; }
/* Sync */
.settings-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; display: inline-flex; align-items: center; border-radius: 4px; margin-left: auto; }
.settings-btn:hover { color: #ccc; background: #222233; }
.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.sidebar-sync-label { flex: 1; text-align: left; }
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
.modal-sync { width: 460px; }
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }

View File

@ -27,6 +27,7 @@ type SyncConfig struct {
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
SyncInterval int `yaml:"sync_interval"`
}
type BrowserConfig struct {

View File

@ -45,6 +45,40 @@ func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
return resp.APIKey, nil
}
// RegisterDeviceWithAuth registers a device with user credentials.
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
body := map[string]string{"name": name, "username": username, "password": password}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
// Temporarily clear API key for this request (server expects login/password, not API key).
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/device/register", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.APIKey, nil
}
// Login authenticates with user credentials and returns a session token.
func (c *Client) Login(username, password string) (token string, err error) {
body := map[string]string{"username": username, "password": password}
var resp struct {
Token string `json:"token"`
}
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/auth/login", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", err
}
return resp.Token, nil
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`