diff --git a/README.md b/README.md index 2715607..e83ae32 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Email OTP login with constant-time hash verification, role-based access checked ### 🌍 Self-Hosted -Single Go binary + PostgreSQL + (optional) S3-compatible storage for release artifacts. No Redis, no microservices. Auto-migration on startup. Setup wizard for first run. Custom branding, email templates, and i18n (English/Chinese built-in). +Single Go binary + PostgreSQL + (optional) S3-compatible storage for release artifacts. No Redis, no microservices. Auto-migration on startup. Setup wizard for first run. Custom branding, email templates, and i18n (English/Chinese/Turkish built-in).
diff --git a/internal/service/email.go b/internal/service/email.go index 0aa0038..bfc1108 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -38,7 +38,56 @@ type EmailService struct { tlsConfig *tls.Config } -func (s *EmailService) IsConfigured() bool { return s.enabled } +type smtpConfig struct { + host string + port string + username string + password string + from string + enabled bool +} + +// getConfig returns the active SMTP configuration. Settings stored +// dynamically in the database (via Admin Settings) take precedence over +// the startup environment variables (s.host, s.port, etc.), allowing +// administrators to configure or update SMTP from the web dashboard +// without restarting the server. +func (s *EmailService) getConfig() smtpConfig { + cfg := smtpConfig{ + host: s.host, + port: s.port, + username: s.username, + password: s.password, + from: s.from, + } + if s.store != nil { + settings, err := s.store.GetSettings(context.Background()) + if err == nil { + if v, ok := settings["smtp_host"]; ok && strings.TrimSpace(v) != "" { + cfg.host = strings.TrimSpace(v) + } + if v, ok := settings["smtp_port"]; ok && strings.TrimSpace(v) != "" { + cfg.port = strings.TrimSpace(v) + } + if v, ok := settings["smtp_username"]; ok && (strings.TrimSpace(v) != "" || settings["smtp_host"] != "") { + cfg.username = strings.TrimSpace(v) + } + if v, ok := settings["smtp_password"]; ok && v != "" { + cfg.password = v + } + if v, ok := settings["smtp_from"]; ok && strings.TrimSpace(v) != "" { + cfg.from = strings.TrimSpace(v) + } + } + } + if cfg.port == "" { + cfg.port = "587" + } + cfg.enabled = cfg.host != "" && cfg.from != "" + return cfg +} + +func (s *EmailService) IsConfigured() bool { return s.getConfig().enabled } func NewEmailService(host, port, username, password, from string, logger *slog.Logger, s *store.Store) *EmailService { enabled := host != "" && from != "" @@ -80,7 +129,8 @@ func DefaultTemplates() map[string]string { } func (s *EmailService) Send(to, subject, htmlBody string) error { - if !s.enabled { + cfg := s.getConfig() + if !cfg.enabled { s.logger.Info("email skipped (not configured)", "to", to, "subject", subject) return nil } @@ -91,7 +141,7 @@ func (s *EmailService) Send(to, subject, htmlBody string) error { } msg := strings.Join([]string{ - "From: " + s.from, + "From: " + cfg.from, "To: " + to, "Subject: " + subject, "MIME-Version: 1.0", @@ -100,13 +150,13 @@ func (s *EmailService) Send(to, subject, htmlBody string) error { htmlBody, }, "\r\n") - addr := s.host + ":" + s.port - err := s.sendOnce(addr, to, []byte(msg)) + addr := cfg.host + ":" + cfg.port + err := s.sendOnceWithConfig(cfg, addr, to, []byte(msg)) if err != nil { // Retry once after a short delay — transient TCP / TLS hiccups. s.logger.Warn("email send failed, retrying", "to", to, "error", err) time.Sleep(3 * time.Second) - err = s.sendOnce(addr, to, []byte(msg)) + err = s.sendOnceWithConfig(cfg, addr, to, []byte(msg)) if err != nil { s.logger.Error("email send failed after retry", "to", to, "error", err) return fmt.Errorf("email send: %w", err) @@ -125,10 +175,10 @@ func (s *EmailService) Send(to, subject, htmlBody string) error { // "504 5.7.4 Unrecognized authentication type"). // // Order of operations: -// 1. TCP dial. +// 1. TCP dial (or direct TLS dial if port == 465). // 2. EHLO (records server-advertised extensions). -// 3. STARTTLS if advertised — Office 365 / Gmail / most modern -// submission endpoints require it on port 587. +// 3. STARTTLS if advertised and not already on TLS — Office 365 / +// Gmail / most modern submission endpoints require it on port 587. // 4. EHLO again (Client.StartTLS does this internally; the // extension list refreshes — AUTH only appears post-TLS on // stricter servers). @@ -144,6 +194,11 @@ func (s *EmailService) Send(to, subject, htmlBody string) error { // own guard. That's the safe default; relay-style deployments that // genuinely want plaintext auth can run their own postfix in front. func (s *EmailService) sendOnce(addr, to string, msg []byte) error { + cfg := s.getConfig() + return s.sendOnceWithConfig(cfg, addr, to, msg) +} + +func (s *EmailService) sendOnceWithConfig(cfg smtpConfig, addr, to string, msg []byte) error { // The SMTP envelope sender (MAIL FROM, RFC 5321) must be a BARE // address — "noreply@x.com", never "Keygate ". // The display-name form is only legal in the RFC 5322 "From:" @@ -151,9 +206,9 @@ func (s *EmailService) sendOnce(addr, to string, msg []byte) error { // Postmark reject a display-name envelope with // "501 Bad sender address syntax". Parse once here so operators // can keep configuring the friendly form in SMTP_FROM. - envelopeFrom, err := parseEnvelopeAddress(s.from) + envelopeFrom, err := parseEnvelopeAddress(cfg.from) if err != nil { - return fmt.Errorf("invalid SMTP_FROM %q: %w", s.from, err) + return fmt.Errorf("invalid SMTP_FROM %q: %w", cfg.from, err) } // CR/LF guard (SMTP injection) — same as net/smtp.SendMail. if err := validateSMTPLine(envelopeFrom); err != nil { @@ -163,11 +218,22 @@ func (s *EmailService) sendOnce(addr, to string, msg []byte) error { return err } - conn, err := net.DialTimeout("tcp", addr, 30*time.Second) + var conn net.Conn + dialer := &net.Dialer{Timeout: 30 * time.Second} + tlsConf := s.tlsConfig + if tlsConf == nil { + tlsConf = &tls.Config{ServerName: cfg.host, MinVersion: tls.VersionTLS12} + } + + if cfg.port == "465" { + conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConf) + } else { + conn, err = dialer.Dial("tcp", addr) + } if err != nil { return fmt.Errorf("dial: %w", err) } - c, err := smtp.NewClient(conn, s.host) + c, err := smtp.NewClient(conn, cfg.host) if err != nil { _ = conn.Close() return fmt.Errorf("smtp client: %w", err) @@ -178,20 +244,18 @@ func (s *EmailService) sendOnce(addr, to string, msg []byte) error { return fmt.Errorf("ehlo: %w", err) } - // STARTTLS upgrade if the server advertises it. (Client.StartTLS - // internally re-EHLOs so post-TLS extensions land in c.Extension.) - if ok, _ := c.Extension("STARTTLS"); ok { - cfg := s.tlsConfig - if cfg == nil { - cfg = &tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12} - } - if err := c.StartTLS(cfg); err != nil { - return fmt.Errorf("starttls: %w", err) + // STARTTLS upgrade if not on implicit TLS (port 465) and the server advertises it. + // (Client.StartTLS internally re-EHLOs so post-TLS extensions land in c.Extension.) + if cfg.port != "465" { + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(tlsConf); err != nil { + return fmt.Errorf("starttls: %w", err) + } } } - if s.username != "" { - auth, perr := pickAuth(c, s.host, s.username, s.password) + if cfg.username != "" { + auth, perr := pickAuth(c, cfg.host, cfg.username, cfg.password) if perr != nil { return perr } diff --git a/internal/service/email_auth_test.go b/internal/service/email_auth_test.go index 49408e0..c1233dd 100644 --- a/internal/service/email_auth_test.go +++ b/internal/service/email_auth_test.go @@ -495,3 +495,166 @@ func genSelfSignedCert(t *testing.T) tls.Certificate { } return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv} } + +func newMockImplicitTLSSMTP(t *testing.T, cfg mockSMTPConfig) *mockSMTP { + t.Helper() + cert := genSelfSignedCert(t) + tlsConf := &tls.Config{Certificates: []tls.Certificate{cert}} + ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsConf) + if err != nil { + t.Fatalf("tls listen: %v", err) + } + srv := &mockSMTP{t: t, ln: ln, tlsCert: cert} + srv.wg.Add(1) + go srv.acceptImplicitTLSLoop(cfg) + return srv +} + +func (s *mockSMTP) acceptImplicitTLSLoop(cfg mockSMTPConfig) { + defer s.wg.Done() + for { + conn, err := s.ln.Accept() + if err != nil { + return + } + s.wg.Add(1) + go func(c net.Conn) { + defer s.wg.Done() + defer c.Close() //nolint:errcheck + s.handleImplicitTLS(c, cfg) + }(conn) + } +} + +func (s *mockSMTP) handleImplicitTLS(c net.Conn, cfg mockSMTPConfig) { + fmt.Fprint(c, "220 mockSMTP implicit TLS ready\r\n") + r := bufio.NewReader(c) + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + cmd := strings.TrimSpace(line) + upper := strings.ToUpper(cmd) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + fmt.Fprint(c, "250-mockSMTP\r\n") + if cfg.advertiseAuth != "" { + fmt.Fprintf(c, "250-AUTH %s\r\n", cfg.advertiseAuth) + } + fmt.Fprint(c, "250 HELP\r\n") + + case strings.HasPrefix(upper, "AUTH PLAIN"): + s.mu.Lock() + s.selectedMech = "PLAIN" + s.mu.Unlock() + parts := strings.SplitN(cmd, " ", 3) + if len(parts) < 3 { + fmt.Fprint(c, "501 Syntax error\r\n") + continue + } + raw, err := base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + fmt.Fprint(c, "501 base64 decode\r\n") + continue + } + segments := strings.Split(string(raw), "\x00") + if len(segments) != 3 || segments[1] != cfg.wantUsername || segments[2] != cfg.wantPassword { + fmt.Fprint(c, "535 Authentication credentials invalid\r\n") + continue + } + s.mu.Lock() + s.authSucceeded = true + s.mu.Unlock() + fmt.Fprint(c, "235 Authentication succeeded\r\n") + + case strings.HasPrefix(upper, "MAIL FROM"): + s.mu.Lock() + s.gotFrom = cmd + s.mu.Unlock() + fmt.Fprint(c, "250 OK\r\n") + + case strings.HasPrefix(upper, "RCPT TO"): + s.mu.Lock() + s.gotTo = append(s.gotTo, cmd) + s.mu.Unlock() + fmt.Fprint(c, "250 OK\r\n") + + case upper == "DATA": + fmt.Fprint(c, "354 End data with .\r\n") + var data strings.Builder + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if dl == ".\r\n" || dl == ".\n" { + break + } + data.WriteString(dl) + } + s.mu.Lock() + s.gotData = data.String() + s.mu.Unlock() + fmt.Fprint(c, "250 OK\r\n") + + case upper == "QUIT": + fmt.Fprint(c, "221 Bye\r\n") + return + + default: + fmt.Fprint(c, "502 Command not implemented\r\n") + } + } +} + +func TestSendOnce_ImplicitTLS465(t *testing.T) { + srv := newMockImplicitTLSSMTP(t, mockSMTPConfig{ + advertiseAuth: "PLAIN", + wantUsername: "bob", + wantPassword: "secret", + }) + defer srv.Close() + + svc := &EmailService{ + host: "127.0.0.1", + port: "465", + username: "bob", + password: "secret", + from: "system@keygate.test", + enabled: true, + logger: slog.Default(), + tlsConfig: &tls.Config{InsecureSkipVerify: true, ServerName: "127.0.0.1"}, + } + + addr := fmt.Sprintf("127.0.0.1:%d", srv.Port()) + msg := []byte("Subject: test 465\r\n\r\nhello over implicit tls") + cfg := svc.getConfig() + cfg.port = "465" + if err := svc.sendOnceWithConfig(cfg, addr, "recipient@test.com", msg); err != nil { + t.Fatalf("sendOnceWithConfig failed on 465: %v", err) + } + + if !srv.AuthSucceeded() { + t.Errorf("expected AUTH to succeed over implicit TLS") + } +} + +func TestEmailService_DynamicConfig(t *testing.T) { + // 1. Without store or env: not configured + svc := NewEmailService("", "", "", "", "", slog.Default(), nil) + if svc.IsConfigured() { + t.Errorf("expected IsConfigured() to be false when nothing is set") + } + + // 2. Struct env settings only + svcEnv := NewEmailService("smtp.example.com", "587", "user", "pass", "noreply@example.com", slog.Default(), nil) + if !svcEnv.IsConfigured() { + t.Errorf("expected IsConfigured() to be true with env values") + } + cfg := svcEnv.getConfig() + if cfg.host != "smtp.example.com" || cfg.port != "587" { + t.Errorf("unexpected cfg: %+v", cfg) + } +} + diff --git a/internal/store/store.go b/internal/store/store.go index 612a816..0c4d008 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -204,10 +204,18 @@ func (s *Store) UpsertUser(ctx context.Context, u *model.User) error { if u.ID == "" { u.ID = newID() } - _, err := s.DB.NewInsert().Model(u). - On("CONFLICT (email) DO UPDATE"). - Set("name = EXCLUDED.name, avatar_url = EXCLUDED.avatar_url, updated_at = now()"). - Exec(ctx) + role := u.Role + if role == "" { + role = model.RoleUser + } + _, err := s.DB.NewRaw(` + INSERT INTO users (id, email, name, avatar_url, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, now(), now()) + ON CONFLICT (email) DO UPDATE SET + name = CASE WHEN EXCLUDED.name != '' THEN EXCLUDED.name ELSE users.name END, + avatar_url = CASE WHEN EXCLUDED.avatar_url != '' THEN EXCLUDED.avatar_url ELSE users.avatar_url END, + updated_at = now() + `, u.ID, u.Email, u.Name, u.AvatarURL, role).Exec(ctx) return err } diff --git a/web/src/i18n/index.tsx b/web/src/i18n/index.tsx index b18b9e6..7393d9c 100644 --- a/web/src/i18n/index.tsx +++ b/web/src/i18n/index.tsx @@ -1,12 +1,13 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react" import en from "./locales/en" +import tr from "./locales/tr" import zh from "./locales/zh" -type Locale = "en" | "zh" +type Locale = "en" | "zh" | "tr" type TranslationKeys = keyof typeof en type Translations = Record -const locales: Record = { en, zh } +const locales: Record = { en, zh, tr } interface I18nContextType { locale: Locale @@ -24,6 +25,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { if (saved && locales[saved]) return saved const browserLang = navigator.language.toLowerCase() if (browserLang.startsWith("zh")) return "zh" + if (browserLang.startsWith("tr")) return "tr" return "en" }) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 1ea490e..7024865 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -594,6 +594,120 @@ const en = { "error.unexpected": "An unexpected error occurred.", "error.tryAgain": "Try Again", "error.reload": "Reload Page", + + // Releases + "releases.title": "Releases", + "releases.subtitle": "Distribute software updates to your customers via Sparkle, Velopack, or Tauri.", + "releases.signingKeys": "Signing keys", + "releases.new": "New release", + "releases.noEligibleProducts": "No release-eligible products", + "releases.noEligibleProductsDesc": + "Release feeds are available for desktop and hybrid products only. Your existing products are all SaaS — change a product's type or create a new desktop/hybrid one.", + "releases.noProducts": "No products yet", + "releases.noProductsDesc": "Create a product before publishing releases.", + "releases.manageProducts": "Manage products", + "releases.createProduct": "Create product", + "releases.allChannels": "All channels", + "releases.allStatuses": "All statuses", + "releases.version": "Version", + "releases.channel": "Channel", + "releases.platforms": "Platforms", + "releases.empty": 'No releases yet. Click "New release" to start.', + "releases.belowLatest": "below latest", + "releases.platformsCount": "{count} platform", + "releases.platformsCountPlural": "{count} platforms", + "releases.openDetail": "Open release detail", + "releases.viewManageArtifacts": "View / manage artifacts", + "releases.publish": "Publish", + "releases.awaitingArtifacts": "Awaiting artifacts ({ready}/{total} ready)", + "releases.yank": "Yank", + "releases.unyank": "Unyank", + "releases.deleteDraft": "Delete draft", + "releases.deleteYankInstead": "Delete (yank instead)", + "releases.statusDraft": "draft", + "releases.statusPublished": "published", + "releases.statusYanked": "yanked", + "releases.statusReady": "ready", + "releases.statusPending": "pending", + "releases.unyankTitle": "Unyank v{version}?", + "releases.unyankDesc": + "This restores the release to the public update feed. SDK clients on the affected channel will start receiving v{version} as a valid update again.", + "releases.deleteDraftTitle": "Delete draft release?", + "releases.deleteDraftDesc": + "Permanently removes the draft and its uploaded artifacts. Published or yanked releases cannot be deleted.", + "releases.publishBelowLatestTitle": "Publish a version below current latest?", + "releases.publishBelowLatestDesc": + "Publishing {version}, older than {latest}. Velopack and Tauri reject downgrades by default; Sparkle's standard comparator is not SemVer-aware. This release will appear in your history out of order — appropriate for backports.", + "releases.publishAnyway": "Publish anyway", + "releases.createTitle": "New release", + "releases.createDesc": + "Create the release record. You'll add platform-specific binaries (artifacts) in the next step.", + "releases.displayNameOptional": "Display name (optional)", + "releases.displayNamePlaceholder": "MyApp Pro", + "releases.releaseNotesOptional": "Release notes (optional, markdown)", + "releases.releaseNotesPlaceholder": "What's new in this version...", + "releases.createDraftBtn": "Create draft", + "releases.creating": "Creating...", + "releases.draftCreatedToast": "Draft {version} created. Add artifacts to publish.", + "releases.detailDraftDesc": "Add platform binaries below. Publish when ready.", + "releases.detailNonDraftDesc": "This release is {status}. Artifacts cannot be modified.", + "releases.artifactsHeading": "Artifacts ({count})", + "releases.noArtifacts": "No artifacts yet — add at least one platform before publishing.", + "releases.addArtifactRemaining": "Add artifact ({count} platforms remaining)", + "releases.notUploadedYet": "Not uploaded yet", + "releases.addArtifactTitle": "Add artifact", + "releases.addArtifactDesc": + "Upload a platform binary for {version}. The file goes directly to storage; we sign + finalize on publish.", + "releases.platform": "Platform", + "releases.artifactFile": "Artifact file", + "releases.platformAndFileRequired": "Platform and file are required", + "releases.uploadFailed": "Upload failed: {status} {statusText}", + "releases.reservingSlot": "Reserving artifact slot...", + "releases.uploadingToStorage": "Uploading to storage...", + "releases.computingSha": "Computing SHA-256 + finalizing...", + "releases.artifactUploadedToast": "Artifact for {platform} uploaded", + "releases.upload": "Upload", + "releases.working": "Working...", + "releases.yankTitle": "Yank {version}?", + "releases.yankDesc": + "Yanking removes this release (and ALL its artifacts) from update feeds. Existing installs continue working. Provide a reason — recorded in the audit log.", + "releases.reason": "Reason", + "releases.yankReasonPlaceholder": "Critical bug in v1.2.3 affecting Windows users; rollback recommended.", + "releases.releaseYankedToast": "Release yanked", + "releases.releaseUnyankedToast": "Release unyanked", + "releases.releasePublishedToast": "Release published", + "releases.draftDeletedToast": "Draft deleted", + "releases.signingKeysTitle": "Release signing keys", + "releases.signingKeysDesc": + "Generate an Ed25519 keypair per product. The public key is embedded in your client app; the server signs every release artifact with the private key on publish.", + "releases.noActiveKey": "No active signing key", + "releases.noActiveKeyDesc": "Releases for this product will be unsigned until you generate a key.", + "releases.generateKey": "Generate signing key", + "releases.generating": "Generating...", + "releases.keyGeneratedToast": "Signing key generated", + "releases.activeSigningKey": "Active signing key", + "releases.createdDate": "Created {date}", + "releases.publicKeyLabel": "Public key (Ed25519, base64)", + "releases.publicKeyEmbedHint": + "Embed this in your client app's update verifier (Sparkle SUPublicEDKey in Info.plist, or Tauri pubkey).", + "releases.downloadPem": "Download .pem", + "releases.rotate": "Rotate", + "releases.deactivate": "Deactivate", + "releases.pastKeysHeading": "Past keys ({count})", + "releases.rotatedAt": "rotated {date}", + "releases.rotateTitle": "Rotate signing key?", + "releases.rotateDesc": + "New keypair generated. Old key is preserved in history but inactive. Existing installs with only the old public key embedded will fail to verify new releases.", + "releases.rotateReasonLabel": "Reason (audit log)", + "releases.rotateReasonPlaceholder": "Routine rotation; no key compromise.", + "releases.keyRotatedToast": "Signing key rotated", + "releases.rotating": "Rotating...", + "releases.deactivateTitle": "Deactivate signing key?", + "releases.deactivateDesc": + "Future releases will be UNSIGNED until you generate a new key. Sparkle / Tauri / Velopack clients with strict signature checking will reject unsigned updates.", + "releases.deactivateReasonPlaceholder": "Why are you deactivating?", + "releases.keyDeactivatedToast": "Signing key deactivated", + "releases.deactivating": "Deactivating...", } as const export default en diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts new file mode 100644 index 0000000..d913fac --- /dev/null +++ b/web/src/i18n/locales/tr.ts @@ -0,0 +1,722 @@ +const tr = { + // Ortak (Common) + "common.save": "Kaydet", + "common.cancel": "İptal", + "common.delete": "Sil", + "common.edit": "Düzenle", + "common.create": "Oluştur", + "common.search": "Ara", + "common.filter": "Filtrele", + "common.loading": "Yükleniyor...", + "common.noData": "Veri yok", + "common.confirm": "Onayla", + "common.optional": "isteğe bağlı", + + // Davet kabul sayfası (Accept-invite landing page) + "acceptInvite.processing": "Davetiniz kabul ediliyor…", + "acceptInvite.missingToken": "Davet belirteci (token) eksik", + "acceptInvite.missingTokenDesc": + "Bu bağlantı geçerli bir davet belirteci içermiyor. E-postanızdaki bağlantıyı tekrar açın.", + "acceptInvite.invalidTitle": "Davet geçersiz veya süresi dolmuş", + "acceptInvite.invalidDesc": + "Bu davet zaten kullanılmış, iptal edilmiş veya süresi dolmuş. Ekip yöneticinizden yeni bir davet göndermesini isteyin.", + "acceptInvite.acceptedTitle": "{product} uygulamasına hoş geldiniz", + "acceptInvite.acceptedDesc": + "{role} rolüyle katıldınız. Lisansları ve kullanıcıları yönetmek için portala giriş yapın.", + "acceptInvite.openPortal": "Portalı aç", + "acceptInvite.goHome": "Ana sayfaya dön", + "common.actions": "İşlemler", + "common.status": "Durum", + "common.name": "Ad", + "common.email": "E-posta", + "common.created": "Oluşturulma", + "common.updated": "Güncellenme", + "common.type": "Tür", + "common.active": "Aktif", + "common.inactive": "Pasif", + "common.all": "Tümü", + "common.product": "Ürün", + "common.plan": "Plan", + "common.yes": "Evet", + "common.no": "Hayır", + "common.unlimited": "Sınırsız", + "common.unlimitedSymbol": "∞", + + // Gezinme (Nav) + "nav.dashboard": "Kontrol Paneli", + "nav.products": "Ürünler", + "nav.plans": "Planlar", + "nav.releases": "Sürümler", + "nav.licenses": "Lisanslar", + "nav.apiKeys": "API Anahtarları", + "nav.webhooks": "Webhooks", + "nav.analytics": "Analitik", + "nav.audit": "Denetim Günlüğü", + "nav.catalog": "Katalog", + "nav.licensing": "Lisanslama", + "nav.developer": "Geliştirici", + "nav.insights": "İçgörüler", + "nav.customers": "Müşteriler", + "nav.settings": "Ayarlar", + "nav.sponsor": "Bize Destek Olun", + "nav.portal": "Kullanıcı Portalı", + "nav.logout": "Çıkış Yap", + + // Kontrol Paneli (Dashboard) + "dashboard.title": "Kontrol Paneli", + "dashboard.totalLicenses": "Toplam Lisans", + "dashboard.activeLicenses": "Aktif Lisans", + "dashboard.totalActivations": "Toplam Etkinleştirme", + "dashboard.products": "Ürünler", + "dashboard.subtitle": "Lisans yönetim sisteminize genel bakış.", + "dashboard.statusDistribution": "Lisans Durumu Dağılımı", + "dashboard.recentLicenses": "Son Lisanslar", + "dashboard.updateAvailable": "Keygate {version} sürümü mevcut.", + "dashboard.viewUpdate": "Ayrıntıları Görüntüle", + "dashboard.releaseNotes": "Sürüm Notları", + + // Ürünler (Products) + "products.title": "Ürünler", + "products.subtitle": "Yazılım ürünlerinizi yönetin.", + "products.new": "Yeni Ürün", + "products.slug": "Kısa Ad (Slug)", + "products.empty": "Henüz ürün yok", + "products.deleteConfirm": "Bu işlem geri alınamaz. Mevcut lisansları olan ürünler silinemez.", + "products.createTitle": "Ürün Oluştur", + "products.editTitle": "Ürünü Düzenle", + "products.formDesc": "Aşağıdaki ürün ayrıntılarını doldurun.", + + // Planlar (Plans) + "plans.title": "Planlar", + "plans.subtitle": "Ürünleriniz için lisans planlarını yönetin.", + "plans.new": "Yeni Plan", + "plans.licenseType": "Lisans Türü", + "plans.maxActivations": "Maksimum Etkinleştirme", + "plans.maxSeats": "Maksimum Koltuk (Seats)", + "plans.licenseModel": "Lisans Modeli", + "plans.modelStandard": "Standart", + "plans.modelFloating": "Kayan / Eşzamanlı (Floating)", + "plans.floatingTimeout": "Kayan Lisans Zaman Aşımı (dakika)", + "plans.hintDesktop": + "Masaüstü ürünü — etkinleştirmeleri + otomatik güncelleme akışlarını destekler. Koltuklar devre dışıdır.", + "plans.hintSaas": "SaaS ürünü — koltukları ve kullanımı destekler. Etkinleştirmeler ve sürüm akışları devre dışıdır.", + "plans.hintHybrid": "Hibrit ürün — etkinleştirmeler, koltuklar ve sürüm akışlarının tümü kullanılabilir.", + "plans.notApplicableForType": "Bu ürün türü için geçerli değil", + "plans.trialDays": "Deneme Süresi (Gün)", + "plans.graceDays": "Ek Süre (Grace Period - Gün)", + "plans.billingInterval": "Faturalandırma Aralığı", + "plans.empty": "Henüz plan yok", + "plans.copyCheckoutLink": "Ödeme bağlantısını kopyala", + "plans.linkCopied": "Ödeme bağlantısı kopyalandı!", + "plans.deleteConfirm": "Mevcut lisansları olan planlar silinemez.", + "plans.createTitle": "Plan Oluştur", + "plans.editTitle": "Planı Düzenle", + "plans.formDesc": "Plan ayrıntılarını yapılandırın.", + "plans.entitlements": "Haklar (Entitlements)", + "plans.addEntitlement": "Hak Ekle", + "plans.deleteEntitlementTitle": "{feature} silinsin mi?", + "plans.deleteEntitlementDesc": + "Bu işlem hakkı bu plandan anında kaldırır. Mevcut lisanslar bu özelliğe erişimini kaybeder.", + "plans.stripeMeterEventName": "Stripe sayaç etkinlik adı (isteğe bağlı)", + "plans.stripeMeterEventNameHelp": + "Stripe ölçümlü faturalandırmayı etkinleştirin. Stripe sayacınızdaki event_name ile eşleşmelidir; her kullanım çağrısı bu adla bir sayaç etkinliği gönderir.", + "plans.allProducts": "Tüm ürünler", + + // Lisanslar (Licenses) + "licenses.title": "Lisanslar", + "licenses.subtitle": "Yazılım lisanslarını yönetin.", + "licenses.issue": "Lisans Oluştur", + "licenses.licenseKey": "Lisans Anahtarı", + "licenses.revealKey": "Anahtarı göster", + "licenses.validUntil": "Geçerlilik Tarihi", + "licenses.perpetual": "Süresiz", + "licenses.validUntilOptional": "Geçerlilik tarihi (isteğe bağlı)", + "licenses.validUntilHint": + "Süresiz bir lisans için boş bırakın (deneme sürümleri yine de planın deneme günlerini kullanır).", + "licenses.validUntilEdit": "Bitiş tarihini düzenle", + "licenses.validUntilClear": "Lisansı süresiz yapmak için boş bırakın.", + "licenses.validUntilExpiredHint": + "Bu lisansın süresi dolmuş — tek başına yeni bir tarih lisansı yeniden etkinleştirmez. Yeniden Etkinleştir işlemini de kullanın.", + "licenses.empty": "Lisans bulunamadı", + "licenses.noProducts": "Henüz ürün yok", + "licenses.noProductsDesc": "Lisans oluşturmadan önce bir ürün ve plan tanımlayın.", + "licenses.detail": "Lisans Ayrıntısı", + "licenses.activations": "Etkinleştirmeler", + "licenses.revoke": "İptal Et (Revoke)", + "licenses.revokeConfirm": + "Bu işlem lisansı kalıcı olarak iptal eder. Müşteri erişimini hemen kaybeder. Bu işlem geri alınamaz.", + "licenses.suspend": "Askıya Al", + "licenses.suspendConfirm": + "Bu işlem lisansı askıya alır. Müşteri, lisans yeniden etkinleştirilene kadar yazılımı etkinleştiremez veya kullanamaz.", + "licenses.reinstate": "Yeniden Etkinleştir", + "licenses.refundConfirm": + "Bu işlem ödeme sağlayıcısındaki aboneliği iptal eder ve lisansı iptal eder. Müşteri erişimini hemen kaybeder.", + "licenses.changePlan": "Planı Değiştir", + "licenses.seats": "Koltuklar (Seats)", + "licenses.role": "Rol", + "licenses.active": "Aktif", + "licenses.pending": "Beklemede", + "licenses.removed": "Kaldırıldı", + "licenses.changePlanDesc": "Bu lisans için yeni bir plan seçin.", + "licenses.issueDesc": "Bir kullanıcı için manuel olarak lisans oluşturun.", + "licenses.info": "Bilgi", + "licenses.usage": "Kullanım", + "licenses.validFrom": "Başlangıç Tarihi", + "licenses.payment": "Ödeme", + "licenses.identifier": "Tanımlayıcı", + "licenses.type": "Tür", + "licenses.label": "Etiket", + "licenses.lastVerified": "Son Doğrulama", + "licenses.noActivations": "Henüz etkinleştirme yok.", + "licenses.usageCounters": "Kullanım Sayaçları", + "licenses.noUsageCounters": "Kullanım sayacı yok.", + "licenses.feature": "Özellik", + "licenses.period": "Dönem", + "licenses.periodKey": "Dönem Anahtarı", + "licenses.used": "Kullanılan", + "licenses.updated": "Güncellendi", + "licenses.resetCounter": "Sayacı sıfırla", + "licenses.resetCounterTitle": "{feature} için kullanım sıfırlansın mı?", + "licenses.resetCounterDesc": + "Mevcut dönem için sayaç sıfırlanır. Müşteri hemen tam kotayı tekrar kullanabilir. Bu işlem denetim günlüğüne kaydedilir.", + "licenses.deactivate": "Devre Dışı Bırak", + "licenses.invite": "Üye davet et", + "licenses.inviteTitle": "Ekip üyesi davet et", + "licenses.inviteDesc": + "Bu adrese tek kullanımlık bir kabul bağlantısı göndereceğiz. Bağlantının süresi 7 gün içinde dolar.", + "licenses.sendInvite": "Davet gönder", + "licenses.seatInviteSent": "Davet gönderildi", + "licenses.seatCapReached": + "Koltuk sınırına ulaşıldı. Daha fazla üye davet etmeden önce planı yükseltin veya pasif bir üyeyi kaldırın.", + "licenses.deactivateTitle": "{device} cihazı devre dışı bırakılsın mı?", + "licenses.deactivateDesc": + "Bu cihaz hemen erişimini kaybeder. Müşteri, lisans anahtarını bilen herhangi bir istemciden (bir etkinleştirme yuvası kullanarak) cihazı yeniden etkinleştirebilir.", + "licenses.recentEvents": "Son Olaylar", + "licenses.noUsageEvents": "Kullanım olayı yok.", + "licenses.quantity": "Miktar", + "licenses.ip": "IP", + "licenses.recorded": "Kaydedildi", + "licenses.noSeats": "Atanmış koltuk yok.", + "licenses.invited": "Davet Edildi", + "licenses.accepted": "Kabul Edildi", + + // API Anahtarları (API Keys) + "apiKeys.title": "API Anahtarları", + "apiKeys.subtitle": "Yetki kapsamlarına sahip sunucular arası kimlik bilgileri.", + "apiKeys.new": "Yeni API Anahtarı", + "apiKeys.prefix": "Anahtar Öneki", + "apiKeys.lastUsed": "Son Kullanım", + "apiKeys.empty": "Henüz API anahtarı yok", + "apiKeys.created": "API Anahtarı Oluşturuldu", + "apiKeys.createdDesc": "Bu anahtarı şimdi kopyalayın. Tekrar görüntüleyemeyeceksiniz.", + "apiKeys.deleteConfirm": + "Bu işlem bu anahtarı derhal geçersiz kılacaktır. Bu anahtarı kullanan tüm istemciler çalışmayı durduracaktır.", + "apiKeys.noProductsDesc": "API anahtarları oluşturmadan önce bir ürün oluşturun.", + "apiKeys.scopes": "Yetki Kapsamları (Scopes)", + "apiKeys.scopesNone": "Kapsam seçilmedi", + "apiKeys.lastUsedIP": "Kullanılan IP", + "apiKeys.rotate": "Döndür (Rotate)", + "apiKeys.rotateTitle": "API anahtarını döndür", + "apiKeys.rotateConfirm": + "Mevcut gizli anahtar derhal çalışmayı durduracaktır. Devam etmeden önce bu anahtarı kullanan tüm istemcileri güncelleyin.", + "apiKeys.scopeAdmin": "Yönetici (Admin - tam yetki)", + "apiKeys.scopeAdminDesc": "Tam /admin/* erişimi. Yönetim araçları için.", + "apiKeys.scopeLicensesWrite": "Lisanslar (yazma)", + "apiKeys.scopeLicensesWriteDesc": "Lisans oluşturun, güncelleyin ve yönetin. Ödeme ve tahsisat arka uçları için.", + "apiKeys.scopeReleasesWrite": "Sürümler (yazma)", + "apiKeys.scopeReleasesWriteDesc": "Sürümleri ve paketleri yükleyin. Derlemeleri yayınlayan CI/CD süreçleri için.", + "apiKeys.atLeastOneScope": "En az bir yetki kapsamı seçin.", + + // Webhooks + "webhooks.title": "Webhooks", + "webhooks.subtitle": "Olay bildirimleri için webhook uç noktalarını yönetin.", + "webhooks.new": "Yeni Webhook", + "webhooks.events": "Olaylar", + "webhooks.empty": "Henüz webhook yok", + "webhooks.deliveries": "Teslimat Günlüğü", + "webhooks.test": "Test Et", + "webhooks.secretCreated": "Webhook Oluşturuldu", + "webhooks.secretDesc": "Bu imzalama gizli anahtarını şimdi kopyalayın. Tekrar görüntüleyemeyeceksiniz.", + "webhooks.newDesc": "Olay bildirimlerini almak için bir uç nokta yapılandırın.", + "webhooks.deleteConfirm": "Bu işlem webhook'u kalıcı olarak kaldıracaktır. Olaylar artık iletilmeyecektir.", + "webhooks.noProductsDesc": "Webhook'ları yapılandırmadan önce bir ürün oluşturun.", + + // Analitik (Analytics) + "analytics.title": "Analitik", + "analytics.subtitle": "Zaman içindeki lisans metrikleri, dağılımları ve trendleri.", + "analytics.granularity": "Ayrıntı Düzeyi", + "analytics.daily": "Günlük", + "analytics.weekly": "Haftalık", + "analytics.monthly": "Aylık", + "analytics.trends": "Trendler", + "analytics.breakdowns": "Dağılımlar", + "analytics.usage": "Kullanım", + "analytics.activations": "Etkinleştirmeler", + "analytics.insights": "İçgörüler", + "analytics.totalLicenses": "Toplam Lisans", + "analytics.active": "Aktif", + "analytics.trialing": "Deneme Sürecinde", + "analytics.pastDue": "Vadesi Geçmiş", + "analytics.expired": "Süresi Dolmuş", + "analytics.canceled": "İptal Edilmiş", + "analytics.suspended": "Askıya Alınmış", + "analytics.revoked": "Hükümsüz Kılınmış (Revoked)", + "analytics.totalActivations": "Toplam Etkinleştirme", + "analytics.totalSeats": "Toplam Koltuk", + "analytics.avgActivations": "Ort. Etkinleştirme/Lisans", + "analytics.new": "Yeni", + "analytics.churned": "Kaybedilen (Churned)", + "analytics.netGrowth": "Net Büyüme", + "analytics.byStatus": "Duruma Göre", + "analytics.byPlan": "Plana Göre", + "analytics.byLicenseType": "Lisans Türüne Göre", + "analytics.topFeatures": "Kullanıma Göre Öne Çıkan Özellikler", + "analytics.rank": "Sıra", + "analytics.feature": "Özellik", + "analytics.totalUsage": "Toplam Kullanım", + "analytics.uniqueLicenses": "Benzersiz Lisanslar", + "analytics.activationTrend": "Etkinleştirme Trendi", + "analytics.noData": "Seçilen dönem için veri bulunamadı.", + "analytics.snapshots": "Anlık Görüntüler", + "analytics.growthRate": "Net Büyüme Oranı", + "analytics.trialConversion": "Deneme Sürümü Dönüşümü", + "analytics.newLast30d": "Yeni (Son 30 Gün)", + "analytics.churnedLast30d": "Kaybedilen (Son 30 Gün)", + "analytics.licenseAge": "Lisans Yaşı Dağılımı", + "analytics.monthlyRetention": "Aylık Elde Tutma (Retention)", + "analytics.topUsers": "En Çok Kullananlar", + "analytics.recentActivity": "Son Etkinlik", + + // Denetim Günlüğü (Audit) + "audit.title": "Denetim Günlüğü", + "audit.subtitle": "Sistem genelindeki etkinlik geçmişi.", + "audit.entity": "Varlık", + "audit.action": "İşlem", + "audit.actor": "Gerçekleştiren", + "audit.changes": "Değişiklikler", + "audit.empty": "Denetim günlüğü kaydı yok", + "audit.filterEntity": "Varlık türü", + "audit.allEntities": "Tüm varlıklar", + "audit.filterEntityId": "Varlık kimliğine göre ara", + "audit.filterProduct": "Ürün", + "audit.allProducts": "Tüm ürünler", + + // Müşteriler (Customers) + "customers.title": "Müşteriler", + "customers.subtitle": "{count} müşteri kayıtlı.", + "customers.customer": "Müşteri", + "customers.joined": "Katılım Tarihi", + "customers.lastUpdated": "Son Güncelleme", + "customers.empty": "Henüz müşteri yok", + "customers.detail": "Müşteri Ayrıntısı", + "customers.overview": "Genel Bakış", + "customers.licenses": "Lisanslar", + "customers.subscriptions": "Abonelikler", + "customers.activity": "Etkinlik", + "customers.totalUsage": "Toplam Kullanım", + "customers.activeSeats": "Aktif Koltuklar", + + // Ekip (Team) + "team.title": "Ekip", + "team.subtitle": "Platform yöneticilerini yönetin.", + "team.invite": "Üye Davet Et", + "team.email": "E-posta adresi", + "team.role": "Rol", + "team.remove": "Kaldır", + "team.removeConfirm": "Bu ekip üyesi kaldırılsın mı? Yönetici erişimini kaybedecek.", + "team.empty": "Henüz ekip üyesi yok.", + "team.invited": "Üye başarıyla davet edildi.", + "team.removed": "Üye kaldırıldı.", + "team.ownerOnly": "Yalnızca ekip sahibi ekip üyelerini yönetebilir.", + + // Ayarlar (Settings) + "settings.title": "Ayarlar", + "settings.subtitle": "Sistem yapılandırması ve tercihler.", + "settings.general": "Genel", + "settings.email": "E-posta", + "settings.security": "Güvenlik", + "settings.timezone": "Saat Dilimi", + "settings.timezoneDesc": "Tarih gösterimi ve analitik için varsayılan saat dilimi.", + "settings.siteName": "Site Adı", + "settings.siteNameDesc": "Yönetici panelinde görüntülenen ad.", + "settings.language": "Dil", + "settings.languageDesc": "Arayüz dili.", + "settings.brandColor": "Marka Rengi", + "settings.brandColorDesc": "Arayüz genelinde kullanılan birincil renk. Varsayılan için boş bırakın.", + "settings.logoUrl": "Özel Logo URL'si", + "settings.logoUrlDesc": "Logonuzun URL'si (SVG önerilir). Varsayılan Keygate logosu için boş bırakın.", + "settings.smtpHost": "SMTP Sunucusu", + "settings.smtpPort": "SMTP Bağlantı Noktası (Port)", + "settings.smtpUsername": "SMTP Kullanıcı Adı", + "settings.smtpPassword": "SMTP Parolası", + "settings.smtpPasswordStored": "Kaydedildi — değiştirmemek için boş bırakın", + "settings.smtpPasswordClear": "Kaldır", + "settings.smtpFrom": "Gönderen E-posta", + "settings.smtpFromDesc": "Giden e-postalar için gönderen e-posta adresi.", + "settings.testEmail": "Test E-postası Gönder", + "settings.testEmailDesc": "SMTP yapılandırmanızı doğrulamak için bir test e-postası gönderin.", + "settings.signup": "Kayıtlar", + "settings.signupMode": "Hesap oluşturabilecek kişiler", + "settings.signupOpen": "E-posta adresi olan herkes", + "settings.signupLicensedOnly": "Yalnızca lisansa sahip e-postalar", + "settings.signupModeDesc": + "Kayıtları sınırlandırmak, giriş kodu uç noktasının yabancılara e-posta göndermesini de durdurur. Zaten hesabı olan kullanıcılar her zaman giriş yapabilir.", + "settings.rateLimit": "İstek Sınırlandırma (Rate Limiting)", + "settings.rateLimitApi": "API İstek Sınırı", + "settings.rateLimitApiDesc": "API uç noktaları için dakika başına maksimum istek sayısı.", + "settings.rateLimitAdmin": "Yönetici İstek Sınırı", + "settings.rateLimitAdminDesc": "Yönetici uç noktaları için dakika başına maksimum istek sayısı.", + "settings.webhookConfig": "Webhook Yapılandırması", + "settings.webhookMaxAttempts": "Maksimum Yeniden Deneme Sayısı", + "settings.webhookTimeout": "HTTP Zaman Aşımı (saniye)", + "settings.quotaThreshold": "Kota Uyarı Eşiği", + "settings.quotaThresholdDesc": "Kullanım bu yüzdeye ulaştığında uyarı gönderilir (0-1 arası).", + "settings.emailTemplates": "E-posta Şablonları", + "settings.editTemplate": "Düzenle", + "settings.resetTemplate": "Varsayılana Sıfırla", + "settings.resetTemplateTitle": "{template} şablonu sıfırlansın mı?", + "settings.resetTemplateDesc": + "Özelleştirilmiş HTML içeriğiniz paketlenmiş varsayılan şablonla değiştirilecektir. Bu işlem geri alınamaz — gerekebilirse önce mevcut HTML kodunu kopyalayın.", + "settings.templateSaved": "Şablon kaydedildi.", + "settings.templateReset": "Şablon varsayılana sıfırlandı.", + "settings.templateDesc": + "E-posta şablonlarını özelleştirin. Go şablon sözdizimini kullanın: {{.Product}}, {{.Plan}}, {{.LicenseKey}}, vb.", + "settings.templatePreview": "Önizleme", + "settings.templateVariables": "Değişkenler", + "settings.system": "Sistem", + "settings.versionInfo": "Sürüm Bilgisi", + "settings.currentVersion": "Mevcut Sürüm", + "settings.commitHash": "Commit", + "settings.buildDate": "Derleme Tarihi", + "settings.checkUpdate": "Güncellemeleri Denetle", + "settings.updateAvailable": "Sürüm {version} mevcut!", + "settings.viewRelease": "Sürümü Görüntüle", + "settings.upToDate": "Sisteminiz güncel", + "settings.changelog": "Yenilikler", + "settings.migrations": "Veritabanı Geçişleri", + "settings.saved": "Ayarlar başarıyla kaydedildi.", + "settings.saveError": "Ayarlar kaydedilemedi.", + "settings.testEmailSent": "Test e-postası gönderildi.", + "settings.testEmailFailed": "Test e-postası gönderilemedi.", + + // Sayfalama (Pagination) + "pagination.of": "/", + "pagination.rows": "Satır", + + // Giriş Yap (Login) + "login.subtitle": "Hesabınıza giriş yapın", + "login.signInWith": "{provider} ile giriş yap", + "login.signingIn": "Giriş yapılıyor...", + "login.devLogin": "Geliştirici Girişi", + "login.devNote": + "Yalnızca geliştirme ortamı içindir. Yönetici erişimi için e-postayı ADMIN_EMAILS listesine ekleyin.", + "login.noProviders": "Yapılandırılmış kimlik doğrulama sağlayıcısı yok.", + "login.failed": "Giriş başarısız oldu", + "login.sendCode": "Giriş kodu gönder", + "login.sendingCode": "Gönderiliyor...", + "login.codeSentTo": "Kod {email} adresine gönderildi", + "login.verify": "Doğrula", + "login.verifying": "Doğrulanıyor...", + "login.changeEmail": "Farklı bir e-posta kullan", + "login.resendCode": "Kodu tekrar gönder", + "login.resendIn": "{seconds} sn içinde tekrar gönder", + "login.or": "veya", + + // Ödeme / Satın Alma (Checkout) + "checkout.verifying": "Ödemeniz doğrulanıyor...", + "checkout.success": "Ödeme başarılı!", + "checkout.licenseSentTo": "Lisans anahtarınız {email} adresine gönderildi", + "checkout.licenseCreated": "Lisans anahtarınız oluşturuldu", + "checkout.error": "Ödeme doğrulanamadı. Lisansınız kısa bir süre içinde e-posta ile gönderilecektir.", + "checkout.viewLicense": "Lisansımı görüntüle", + + // Portal + "portal.myLicenses": "Lisanslarım", + "portal.myLicensesDesc": "Yazılım lisanslarınızı, kullanımınızı ve ekip üyelerinizi yönetin.", + "portal.noLicenses": "Henüz lisans yok", + "portal.noLicensesDesc": "Satın aldığınız lisanslar burada görüntülenecektir.", + "portal.validFrom": "Başlangıç Tarihi", + "portal.noDevices": "Aktif cihaz yok.", + "portal.activeDevices": "Aktif Cihazlar", + "portal.lastVerified": "Son doğrulama:", + "portal.noEntitlements": "Yapılandırılmış hak bulunmuyor.", + "portal.featuresIncluded": "Dahil Olan Özellikler", + "portal.teamMembers": "Ekip Üyeleri", + "portal.addMember": "Üye Ekle", + "portal.removeMember": "Üyeyi kaldır", + "portal.removeMemberTitle": "Ekip üyesi kaldırılsın mı?", + "portal.removeMemberDesc": "{email} hemen erişimini kaybeder. Daha sonra tekrar davet edebilirsiniz.", + "portal.removeDevice": "Cihazı kaldır", + "portal.removeDeviceTitle": "Bu cihaz kaldırılsın mı?", + "portal.removeDeviceDesc": + "{device} cihazının etkinleştirmesi derhal kaldırılır ve bir yuva boşa çıkar. Yuva mevcutsa cihaz daha sonra tekrar etkinleştirilebilir.", + "portal.noMembers": "Henüz ekip üyesi yok.", + "portal.seatLimit": "Koltuk sınırına ulaşıldı. Daha fazla üye eklemek için planınızı yükseltin.", + "portal.inviteDesc": "Bu lisansa yeni bir üye davet edin.", + "portal.account": "Hesap", + "portal.accountDesc": "Profiliniz ve lisans özetiniz.", + "portal.profile": "Profil", + "portal.editProfile": "Düzenle", + "portal.saveProfile": "Kaydet", + "portal.profileSaved": "Profil güncellendi.", + "portal.displayName": "Görünen Ad", + "portal.accountType": "Hesap Türü", + "portal.administrator": "Yönetici", + "portal.userRole": "Kullanıcı", + "portal.licenseOverview": "Lisans Özeti", + "portal.noLicensesAccount": "Hesabınızla ilişkili bir lisans bulunmuyor.", + "portal.roleMember": "Üye", + "portal.roleAdmin": "Yönetici", + "portal.viewInvoices": "Faturaları Görüntüle", + "portal.updatePayment": "Ödeme Yöntemini Güncelle", + "portal.changePlan": "Planı Değiştir", + "portal.cancelSubscription": "Aboneliği İptal Et", + "portal.cancelDesc": "{product} aboneliğinizi iptal etmek istediğinizden emin misiniz?", + "portal.cancelImmediately": "Hemen iptal et (erişimi şimdi sonlandır)", + "portal.cancelImmediateWarning": "Erişiminiz derhal sona erecektir. Bu işlem geri alınamaz.", + "portal.cancelEndWarning": "Mevcut faturalandırma döneminizin sonuna kadar erişiminizi korursunuz.", + "portal.confirmCancel": "İptali Onayla", + "portal.invoices": "Faturalar", + "portal.changePlanDesc": "Yeni bir plan seçin. Değişiklikler orantılı faturalandırma ile hemen yürürlüğe girer.", + "portal.noOtherPlans": "Başka plan bulunmuyor.", + "portal.switchTo": "Geçiş Yap", + + // Filtreler (Filters) + "filter.allProducts": "Tüm ürünler", + "filter.allStatuses": "Tüm durumlar", + "filter.allPlans": "Tüm planlar", + "filter.allTypes": "Tüm türler", + + // Durum değerleri (Status values) + "status.active": "Aktif", + "status.trialing": "Deneme", + "status.past_due": "Vadesi Geçmiş", + "status.canceled": "İptal Edildi", + "status.expired": "Süresi Doldu", + "status.suspended": "Askıya Alındı", + "status.revoked": "Hükümsüz (Revoked)", + "status.pending": "Beklemede", + "status.delivered": "Teslim Edildi", + "status.failed": "Başarısız", + "status.paused": "Duraklatıldı", + + // Webhooks ekleri + "webhooks.inactive": "Pasif", + "webhooks.event": "Olay", + "webhooks.response": "Yanıt", + "webhooks.attempts": "Denemeler", + "webhooks.delivered": "Teslim Edildi", + "webhooks.noDeliveries": "Henüz teslimat yok.", + "webhooks.deliveriesCount": "toplam teslimat", + "webhooks.statusAll": "Tümü", + "webhooks.resend": "Tekrar Gönder", + "webhooks.resendQueued": "Tekrar gönderim sıraya alındı", + "webhooks.resendFailed": "Tekrar gönderim başarısız oldu", + "webhooks.testQueued": "Test olayı gönderildi", + "webhooks.testFailed": "Test gönderimi başarısız oldu", + + // Planlar ekleri (Plans extras) + "plans.subscription": "Abonelik", + "plans.perpetual": "Süresiz (Perpetual)", + "plans.trial": "Deneme", + "plans.none": "Yok", + "plans.monthly": "Aylık", + "plans.yearly": "Yıllık", + "plans.stripePriceId": "Stripe Fiyat Kimliği (Price ID)", + "plans.valueType": "Değer Türü", + "plans.value": "Değer", + "plans.quotaPeriod": "Kota Dönemi", + "plans.quotaUnit": "Kota Birimi", + "plans.hourly": "Saatlik", + "plans.daily": "Günlük", + "plans.boolean": "Boolean", + "plans.integer": "Tamsayı (Integer)", + "plans.string": "Metin (String)", + "plans.quota": "Kota", + "plans.feature": "Özellik", + + // Ürünler ekleri (Products extras) + "products.desktop": "Masaüstü (Desktop)", + "products.desktopDesc": "Yüklenebilir uygulama. Cihaz başına etkinleştirme + otomatik güncelleme akışları.", + "products.saas": "SaaS", + "products.saasDesc": "Barındırılan hizmet. Kullanıcı başına koltuk + kullanım ölçümü.", + "products.hybrid": "Hibrit", + "products.hybridDesc": "Bulut + yüklenebilir istemci. Tüm yetenekler kullanılabilir.", + + // Lisanslar ekleri (Licenses extras) + "licenses.notes": "Notlar", + "licenses.notesOptional": "Notlar (isteğe bağlı)", + "licenses.externalCustomerID": "Harici Müşteri Kimliği", + "licenses.externalWorkspaceID": "Harici Çalışma Alanı Kimliği", + "licenses.externalIDPlaceholder": "İsteğe bağlı. Kendi kullanıcı veya kiracı kimliğiniz.", + + // Analitik ekleri (Analytics extras) + "analytics.period": "Dönem", + "analytics.start": "Başlangıç", + "analytics.end": "Bitiş", + "analytics.retentionPct": "Elde Tutma %", + "analytics.churnPct": "Kayıp %", + "analytics.seats": "Koltuklar", + "analytics.weekOf": "Haftası", + "analytics.month": "Ay", + "analytics.date": "Tarih", + "analytics.noUsageData": "Kullanım verisi bulunmuyor.", + "analytics.noActivationData": "Etkinleştirme verisi bulunmuyor.", + "analytics.noUserData": "Kullanıcı verisi bulunmuyor.", + "analytics.noRecentActivity": "Son etkinlik yok.", + "analytics.loadingInsights": "İçgörüler yükleniyor...", + "analytics.converted": "dönüştürüldü", + "analytics.rawData": "Ham Veri", + + // Kullanıcılar / Müşteriler ekleri (Users extras) + "customers.provider": "Sağlayıcı", + "customers.periodRange": "Dönem", + "customers.cancelAtEnd": "Dönem Sonunda İptal Et", + + // Lisans iade (Licenses refund) + "licenses.refund": "İade Et", + + // Eklentiler (Addons) + "nav.addons": "Eklentiler", + "addons.title": "Eklentiler", + "addons.subtitle": "Satın alınabilir özellik eklentilerini yönetin.", + "addons.new": "Yeni Eklenti", + "addons.edit": "Eklentiyi Düzenle", + "addons.empty": "Henüz eklenti yok", + "addons.deleteConfirm": "Bu işlem bu eklentiyi kalıcı olarak kaldıracaktır.", + "addons.formDesc": "Eklenti ayrıntılarını yapılandırın.", + "addons.description": "Açıklama", + + // Bildirim mesajları (Toast messages) + "toast.productCreated": "Ürün başarıyla oluşturuldu.", + "toast.productDeleted": "Ürün silindi.", + "toast.planCreated": "Plan başarıyla oluşturuldu.", + "toast.planDeleted": "Plan silindi.", + "toast.licenseCreated": "Lisans başarıyla oluşturuldu.", + "toast.apiKeyCreated": "API anahtarı başarıyla oluşturuldu.", + "toast.addonCreated": "Eklenti başarıyla oluşturuldu.", + "toast.webhookCreated": "Webhook başarıyla oluşturuldu.", + + // Hata ekranı (Error boundary) + "error.title": "Bir şeyler ters gitti", + "error.unexpected": "Beklenmeyen bir hata oluştu.", + "error.tryAgain": "Tekrar Dene", + "error.reload": "Sayfayı Yenile", + + // Sürümler (Releases) + "releases.title": "Sürümler", + "releases.subtitle": "Sparkle, Velopack veya Tauri aracılığıyla müşterilerinize yazılım güncellemeleri dağıtın.", + "releases.signingKeys": "İmzalama anahtarları", + "releases.new": "Yeni sürüm", + "releases.noEligibleProducts": "Sürüm için uygun ürün bulunamadı", + "releases.noEligibleProductsDesc": + "Sürüm akışları yalnızca masaüstü ve hibrit ürünler için geçerlidir. Mevcut ürünlerinizin tümü SaaS türündedir — bir ürünün türünü değiştirin veya yeni bir masaüstü/hibrit ürün oluşturun.", + "releases.noProducts": "Henüz ürün yok", + "releases.noProductsDesc": "Sürüm yayınlamadan önce bir ürün oluşturun.", + "releases.manageProducts": "Ürünleri yönet", + "releases.createProduct": "Ürün oluştur", + "releases.allChannels": "Tüm kanallar", + "releases.allStatuses": "Tüm durumlar", + "releases.version": "Sürüm", + "releases.channel": "Kanal", + "releases.platforms": "Platformlar", + "releases.empty": 'Henüz sürüm yok. Başlamak için "Yeni sürüm" butonuna tıklayın.', + "releases.belowLatest": "en son sürümün altında", + "releases.platformsCount": "{count} platform", + "releases.platformsCountPlural": "{count} platform", + "releases.openDetail": "Sürüm ayrıntısını aç", + "releases.viewManageArtifacts": "Paketleri görüntüle / yönet", + "releases.publish": "Yayınla", + "releases.awaitingArtifacts": "Paketler bekleniyor ({ready}/{total} hazır)", + "releases.yank": "Geri Çek (Yank)", + "releases.unyank": "Geri Çekmeyi Kaldır (Unyank)", + "releases.deleteDraft": "Taslağı sil", + "releases.deleteYankInstead": "Sil (bunun yerine geri çekin)", + "releases.statusDraft": "taslak", + "releases.statusPublished": "yayınlandı", + "releases.statusYanked": "geri çekildi", + "releases.statusReady": "hazır", + "releases.statusPending": "beklemede", + "releases.unyankTitle": "v{version} geri çekilmesi kaldırılsın mı?", + "releases.unyankDesc": + "Bu işlem sürümü genel güncelleme akışına geri yükler. İlgili kanaldaki SDK istemcileri v{version} sürümünü yeniden geçerli bir güncelleme olarak almaya başlar.", + "releases.deleteDraftTitle": "Taslak sürüm silinsin mi?", + "releases.deleteDraftDesc": + "Taslağı ve yüklenen paketlerini kalıcı olarak siler. Yayınlanmış veya geri çekilmiş sürümler silinemez.", + "releases.publishBelowLatestTitle": "Mevcut en son sürümün altında bir sürüm yayınlansın mı?", + "releases.publishBelowLatestDesc": + "{latest} sürümünden daha eski olan {version} sürümü yayınlanıyor. Velopack ve Tauri sürüm düşürmelerini varsayılan olarak reddeder; Sparkle'ın standart karşılaştırıcısı SemVer duyarlı değildir. Bu sürüm geçmişinizde sıra dışı görünecektir — ara düzeltmeler (backports) için uygundur.", + "releases.publishAnyway": "Yine de yayınla", + "releases.createTitle": "Yeni sürüm", + "releases.createDesc": + "Sürüm kaydını oluşturun. Platforma özel ikili dosyaları (paketleri) bir sonraki adımda ekleyeceksiniz.", + "releases.displayNameOptional": "Görünen ad (isteğe bağlı)", + "releases.displayNamePlaceholder": "Uygulamam Pro", + "releases.releaseNotesOptional": "Sürüm notları (isteğe bağlı, markdown)", + "releases.releaseNotesPlaceholder": "Bu sürümdeki yenilikler...", + "releases.createDraftBtn": "Taslak oluştur", + "releases.creating": "Oluşturuluyor...", + "releases.draftCreatedToast": "Taslak {version} oluşturuldu. Yayınlamak için paketleri ekleyin.", + "releases.detailDraftDesc": "Aşağıdan platform ikili dosyalarını ekleyin. Hazır olduğunda yayınlayın.", + "releases.detailNonDraftDesc": "Bu sürüm {status} durumundadır. Paketler değiştirilemez.", + "releases.artifactsHeading": "Paketler ({count})", + "releases.noArtifacts": "Henüz paket yok — yayınlamadan önce en az bir platform ekleyin.", + "releases.addArtifactRemaining": "Paket ekle ({count} platform kaldı)", + "releases.notUploadedYet": "Henüz yüklenmedi", + "releases.addArtifactTitle": "Paket ekle", + "releases.addArtifactDesc": + "{version} sürümü için bir platform ikili dosyası yükleyin. Dosya doğrudan depolama alanına gider; yayınlama anında imzalanır ve tamamlanır.", + "releases.platform": "Platform", + "releases.artifactFile": "Paket dosyası", + "releases.platformAndFileRequired": "Platform ve dosya zorunludur", + "releases.uploadFailed": "Yükleme başarısız oldu: {status} {statusText}", + "releases.reservingSlot": "Paket yuvası ayrılıyor...", + "releases.uploadingToStorage": "Depolama alanına yükleniyor...", + "releases.computingSha": "SHA-256 hesaplanıyor ve tamamlanıyor...", + "releases.artifactUploadedToast": "{platform} için paket yüklendi", + "releases.upload": "Yükle", + "releases.working": "İşleniyor...", + "releases.yankTitle": "{version} geri çekilsin mi?", + "releases.yankDesc": + "Geri çekme işlemi bu sürümü (ve TÜM paketlerini) güncelleme akışlarından kaldırır. Mevcut kurulumlar çalışmaya devam eder. Denetim günlüğüne kaydedilmek üzere bir neden belirtin.", + "releases.reason": "Neden", + "releases.yankReasonPlaceholder": + "v1.2.3 sürümünde Windows kullanıcılarını etkileyen kritik hata; geri alma önerilir.", + "releases.releaseYankedToast": "Sürüm geri çekildi", + "releases.releaseUnyankedToast": "Sürümün geri çekilmesi kaldırıldı", + "releases.releasePublishedToast": "Sürüm yayınlandı", + "releases.draftDeletedToast": "Taslak silindi", + "releases.signingKeysTitle": "Sürüm imzalama anahtarları", + "releases.signingKeysDesc": + "Ürün başına bir Ed25519 anahtar çifti oluşturun. Genel anahtar istemci uygulamanıza yerleştirilir; sunucu yayınlama sırasında her sürüm paketini özel anahtarla imzalar.", + "releases.noActiveKey": "Aktif imzalama anahtarı yok", + "releases.noActiveKeyDesc": "Bir anahtar oluşturana kadar bu ürün için sürümler imzasız olacaktır.", + "releases.generateKey": "İmzalama anahtarı oluştur", + "releases.generating": "Oluşturuluyor...", + "releases.keyGeneratedToast": "İmzalama anahtarı oluşturuldu", + "releases.activeSigningKey": "Aktif imzalama anahtarı", + "releases.createdDate": "Oluşturuldu: {date}", + "releases.publicKeyLabel": "Genel anahtar (Ed25519, base64)", + "releases.publicKeyEmbedHint": + "Bunu istemci uygulamanızın güncelleme doğrulayıcısına yerleştirin (Info.plist içindeki Sparkle SUPublicEDKey veya Tauri pubkey).", + "releases.downloadPem": ".pem İndir", + "releases.rotate": "Döndür", + "releases.deactivate": "Devre Dışı Bırak", + "releases.pastKeysHeading": "Geçmiş anahtarlar ({count})", + "releases.rotatedAt": "döndürüldü: {date}", + "releases.rotateTitle": "İmzalama anahtarı döndürülsün mü?", + "releases.rotateDesc": + "Yeni anahtar çifti oluşturuldu. Eski anahtar geçmişte saklanır ancak pasiftir. Yalnızca eski genel anahtara sahip mevcut kurulumlar yeni sürümleri doğrulayamaz.", + "releases.rotateReasonLabel": "Neden (denetim günlüğü)", + "releases.rotateReasonPlaceholder": "Rutin anahtar değişimi; güvenlik ihlali yok.", + "releases.keyRotatedToast": "İmzalama anahtarı döndürüldü", + "releases.rotating": "Döndürülüyor...", + "releases.deactivateTitle": "İmzalama anahtarı devre dışı bırakılsın mı?", + "releases.deactivateDesc": + "Yeni bir anahtar oluşturana kadar gelecekteki sürümler İMZASIZ olacaktır. Katı imza kontrolü yapan Sparkle / Tauri / Velopack istemcileri imzasız güncellemeleri reddedecektir.", + "releases.deactivateReasonPlaceholder": "Neden devre dışı bırakıyorsunuz?", + "releases.keyDeactivatedToast": "İmzalama anahtarı devre dışı bırakıldı", + "releases.deactivating": "Devre dışı bırakılıyor...", +} as const + +export default tr diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index ff6d72d..49d3d2d 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -582,6 +582,117 @@ const zh = { "error.unexpected": "发生了意外错误。", "error.tryAgain": "重试", "error.reload": "刷新页面", + + // 发布管理 (Releases) + "releases.title": "发布管理", + "releases.subtitle": "通过 Sparkle、Velopack 或 Tauri 向客户分发软件更新。", + "releases.signingKeys": "签名密钥", + "releases.new": "新建发布", + "releases.noEligibleProducts": "暂无可发布的符合条件的产品", + "releases.noEligibleProductsDesc": + "发布源仅适用于桌面版和混合型产品。您现有的产品均为 SaaS — 请更改产品类型或创建新的桌面/混合产品。", + "releases.noProducts": "暂无产品", + "releases.noProductsDesc": "发布版本前请先创建产品。", + "releases.manageProducts": "管理产品", + "releases.createProduct": "创建产品", + "releases.allChannels": "所有通道", + "releases.allStatuses": "所有状态", + "releases.version": "版本", + "releases.channel": "通道", + "releases.platforms": "平台", + "releases.empty": "暂无发布版本。点击“新建发布”开始。", + "releases.belowLatest": "低于最新版本", + "releases.platformsCount": "{count} 个平台", + "releases.platformsCountPlural": "{count} 个平台", + "releases.openDetail": "打开发布详情", + "releases.viewManageArtifacts": "查看 / 管理发布包", + "releases.publish": "发布", + "releases.awaitingArtifacts": "等待发布包 ({ready}/{total} 就绪)", + "releases.yank": "撤回 (Yank)", + "releases.unyank": "取消撤回 (Unyank)", + "releases.deleteDraft": "删除草稿", + "releases.deleteYankInstead": "删除 (请使用撤回替代)", + "releases.statusDraft": "草稿", + "releases.statusPublished": "已发布", + "releases.statusYanked": "已撤回", + "releases.statusReady": "就绪", + "releases.statusPending": "等待中", + "releases.unyankTitle": "取消撤回 v{version}?", + "releases.unyankDesc": + "这将恢复该发布版本至公共更新源。受影响通道上的 SDK 客户端将重新将 v{version} 作为有效更新接收。", + "releases.deleteDraftTitle": "删除草稿版本?", + "releases.deleteDraftDesc": "将永久删除该草稿及其上传的发布包。已发布或已撤回的版本无法删除。", + "releases.publishBelowLatestTitle": "发布低于当前最新版本的版本?", + "releases.publishBelowLatestDesc": + "正在发布 {version},该版本早于 {latest}。Velopack 和 Tauri 默认拒绝降级;Sparkle 标准比较器不感知 SemVer。此发布版本将乱序显示在您的历史记录中 — 适用于补丁回溯 (backports)。", + "releases.publishAnyway": "仍然发布", + "releases.createTitle": "新建发布", + "releases.createDesc": "创建发布记录。您将在下一步添加特定平台的二进制文件(发布包)。", + "releases.displayNameOptional": "显示名称(可选)", + "releases.displayNamePlaceholder": "MyApp Pro", + "releases.releaseNotesOptional": "发布说明(可选,Markdown)", + "releases.releaseNotesPlaceholder": "此版本的新特性...", + "releases.createDraftBtn": "创建草稿", + "releases.creating": "创建中...", + "releases.draftCreatedToast": "草稿 {version} 已创建。添加发布包后即可发布。", + "releases.detailDraftDesc": "在下方添加平台二进制文件。就绪后即可发布。", + "releases.detailNonDraftDesc": "此发布版本处于 {status} 状态。发布包无法修改。", + "releases.artifactsHeading": "发布包 ({count})", + "releases.noArtifacts": "暂无发布包 — 发布前请至少添加一个平台。", + "releases.addArtifactRemaining": "添加发布包 (还剩 {count} 个平台)", + "releases.notUploadedYet": "尚未上传", + "releases.addArtifactTitle": "添加发布包", + "releases.addArtifactDesc": "上传 {version} 的平台二进制文件。文件将直接传输至存储;我们在发布时签名并最终确认。", + "releases.platform": "平台", + "releases.artifactFile": "发布包文件", + "releases.platformAndFileRequired": "平台和文件为必填项", + "releases.uploadFailed": "上传失败:{status} {statusText}", + "releases.reservingSlot": "正在预留发布包位置...", + "releases.uploadingToStorage": "正在上传至存储...", + "releases.computingSha": "正在计算 SHA-256 并确认...", + "releases.artifactUploadedToast": "{platform} 的发布包已上传", + "releases.upload": "上传", + "releases.working": "处理中...", + "releases.yankTitle": "撤回 {version}?", + "releases.yankDesc": + "撤回将从更新源中移除此版本(及其所有发布包)。现有安装将继续运行。请提供原因 — 将记录在审计日志中。", + "releases.reason": "原因", + "releases.yankReasonPlaceholder": "v1.2.3 中影响 Windows 用户的严重缺陷;建议回滚。", + "releases.releaseYankedToast": "发布已撤回", + "releases.releaseUnyankedToast": "发布已取消撤回", + "releases.releasePublishedToast": "发布已上线", + "releases.draftDeletedToast": "草稿已删除", + "releases.signingKeysTitle": "发布签名密钥", + "releases.signingKeysDesc": + "为每个产品生成一个 Ed25519 密钥对。公钥嵌入在您的客户端应用中;服务器在发布时使用私钥对每个发布包进行签名。", + "releases.noActiveKey": "暂无活动签名密钥", + "releases.noActiveKeyDesc": "在生成密钥之前,此产品的发布将不包含签名。", + "releases.generateKey": "生成签名密钥", + "releases.generating": "生成中...", + "releases.keyGeneratedToast": "签名密钥已生成", + "releases.activeSigningKey": "活动签名密钥", + "releases.createdDate": "创建于 {date}", + "releases.publicKeyLabel": "公钥 (Ed25519, base64)", + "releases.publicKeyEmbedHint": + "将此嵌入到客户端应用的更新验证程序中(Info.plist 中的 Sparkle SUPublicEDKey,或 Tauri pubkey)。", + "releases.downloadPem": "下载 .pem", + "releases.rotate": "轮换", + "releases.deactivate": "停用", + "releases.pastKeysHeading": "历史密钥 ({count})", + "releases.rotatedAt": "轮换于 {date}", + "releases.rotateTitle": "轮换签名密钥?", + "releases.rotateDesc": + "已生成新密钥对。旧密钥保留在历史记录中但处于非活动状态。仅嵌入旧公钥的现有安装将无法验证新发布。", + "releases.rotateReasonLabel": "原因(审计日志)", + "releases.rotateReasonPlaceholder": "常规轮换;无密钥泄漏。", + "releases.keyRotatedToast": "签名密钥已轮换", + "releases.rotating": "轮换中...", + "releases.deactivateTitle": "停用签名密钥?", + "releases.deactivateDesc": + "在生成新密钥之前,未来的发布将为未签名状态。具有严格签名检查的 Sparkle / Tauri / Velopack 客户端将拒绝未签名的更新。", + "releases.deactivateReasonPlaceholder": "您停用的原因是什么?", + "releases.keyDeactivatedToast": "签名密钥已停用", + "releases.deactivating": "停用中...", } as const export default zh diff --git a/web/src/pages/admin/releases.tsx b/web/src/pages/admin/releases.tsx index f3647f9..35cd610 100644 --- a/web/src/pages/admin/releases.tsx +++ b/web/src/pages/admin/releases.tsx @@ -50,6 +50,7 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useI18n } from "@/i18n" import { admin, RELEASE_CHANNELS, @@ -63,6 +64,7 @@ import { formatDate } from "@/lib/utils" const PAGE_SIZE = 20 export default function ReleasesPage() { + const { t } = useI18n() const qc = useQueryClient() const [productFilter, setProductFilter] = useState("") const [channelFilter, setChannelFilter] = useState("") @@ -105,7 +107,7 @@ export default function ReleasesPage() { mutationFn: admin.publishRelease, onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "releases"] }) - showToast("Release published", "success") + showToast(t("releases.releasePublishedToast"), "success") }, onError: (e: Error) => showToast(e.message, "error"), }) @@ -113,7 +115,7 @@ export default function ReleasesPage() { mutationFn: admin.unyankRelease, onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "releases"] }) - showToast("Release unyanked", "success") + showToast(t("releases.releaseUnyankedToast"), "success") setUnyanking(null) }, onError: (e: Error) => showToast(e.message, "error"), @@ -123,7 +125,7 @@ export default function ReleasesPage() { onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "releases"] }) setDeleting(null) - showToast("Draft deleted", "success") + showToast(t("releases.draftDeletedToast"), "success") }, onError: (e: Error) => showToast(e.message, "error"), }) @@ -135,31 +137,27 @@ export default function ReleasesPage() { return (
-

Releases

-

- Distribute software updates to your customers via Sparkle, Velopack, or Tauri. -

+

{t("releases.title")}

+

{t("releases.subtitle")}

{hasAnyProducts ? ( <> -

No release-eligible products

-

- Release feeds are available for desktop and hybrid products only. Your existing products are all SaaS - — change a product's type or create a new desktop/hybrid one. -

+

{t("releases.noEligibleProducts")}

+

{t("releases.noEligibleProductsDesc")}

) : ( <> -

No products yet

-

Create a product before publishing releases.

+

{t("releases.noProducts")}

+

{t("releases.noProductsDesc")}

)}
@@ -172,17 +170,15 @@ export default function ReleasesPage() {
-

Releases

-

- Distribute software updates to your customers via Sparkle, Velopack, or Tauri. -

+

{t("releases.title")}

+

{t("releases.subtitle")}

@@ -190,10 +186,10 @@ export default function ReleasesPage() {
@@ -230,20 +226,20 @@ export default function ReleasesPage() { - Product - Version - Channel - Platforms - Status - Created - Actions + {t("common.product")} + {t("releases.version")} + {t("releases.channel")} + {t("releases.platforms")} + {t("common.status")} + {t("common.created")} + {t("common.actions")} {isLoading ? ( - + ) : releases.length === 0 ? ( - + ) : ( releases.map((rel) => { const bucketKey = `${rel.product_id}|${rel.channel}` @@ -263,7 +259,7 @@ export default function ReleasesPage() { type="button" className="hover:underline" onClick={() => setOpenRelease(rel)} - title="Open release detail" + title={t("releases.openDetail")} > {rel.version} @@ -273,7 +269,7 @@ export default function ReleasesPage() { className="ml-1.5 text-[10px] py-0 px-1.5 border-amber-500 text-amber-700" title={`Below current latest (${latestInBucket})`} > - below latest + {t("releases.belowLatest")} )} @@ -287,7 +283,9 @@ export default function ReleasesPage() { ) : ( - {artifacts.length} platform{artifacts.length === 1 ? "" : "s"} + {artifacts.length === 1 + ? t("releases.platformsCount", { count: 1 }) + : t("releases.platformsCountPlural", { count: artifacts.length })} )} @@ -299,12 +297,12 @@ export default function ReleasesPage() { setOpenRelease(rel)}> - View / manage artifacts + {t("releases.viewManageArtifacts")} {rel.status === "draft" && allReady && ( @@ -317,30 +315,33 @@ export default function ReleasesPage() { } }} > - Publish + {t("releases.publish")} )} {rel.status === "draft" && !allReady && ( - Awaiting artifacts ({artifacts.filter((a) => a.sha256).length}/{artifacts.length} ready) + {t("releases.awaitingArtifacts", { + ready: artifacts.filter((a) => a.sha256).length, + total: artifacts.length, + })} )} {rel.status === "published" && ( setYanking(rel)} className="text-destructive"> - Yank + {t("releases.yank")} )} {rel.status === "yanked" && ( - setUnyanking(rel)}>Unyank + setUnyanking(rel)}>{t("releases.unyank")} )} {rel.status === "draft" ? ( setDeleting(rel)}> - Delete draft + {t("releases.deleteDraft")} ) : ( - Delete (yank instead) + {t("releases.deleteYankInstead")} )} @@ -377,15 +378,16 @@ export default function ReleasesPage() { setUnyanking(null)}> - Unyank v{unyanking.version}? + {t("releases.unyankTitle", { version: unyanking.version })} - This restores the release to the public update feed. SDK clients on the affected channel will start - receiving v{unyanking.version} as a valid update again. + {t("releases.unyankDesc", { version: unyanking.version })}
- Cancel - unyanking && unyankMut.mutate(unyanking.id)}>Unyank + {t("common.cancel")} + unyanking && unyankMut.mutate(unyanking.id)}> + {t("releases.unyank")} +
@@ -395,16 +397,13 @@ export default function ReleasesPage() { setDeleting(null)}> - Delete draft release? - - Permanently removes the draft and its uploaded artifacts. Published or yanked releases cannot be - deleted. - + {t("releases.deleteDraftTitle")} + {t("releases.deleteDraftDesc")}
- Cancel + {t("common.cancel")} deleteMut.mutate(deleting.id)} disabled={deleteMut.isPending}> - Delete + {t("common.delete")}
@@ -414,23 +413,23 @@ export default function ReleasesPage() { setConfirmPublish(null)}> - Publish a version below current latest? + {t("releases.publishBelowLatestTitle")} - Publishing {confirmPublish.rel.version}, older than{" "} - {confirmPublish.latest}. Velopack and Tauri reject downgrades by default; Sparkle's - standard comparator is not SemVer-aware. This release will appear in your history out of order — - appropriate for backports. + {t("releases.publishBelowLatestDesc", { + version: confirmPublish.rel.version, + latest: confirmPublish.latest, + })}
- Cancel + {t("common.cancel")} { publishMut.mutate(confirmPublish.rel.id) setConfirmPublish(null) }} > - Publish anyway + {t("releases.publishAnyway")}
@@ -441,16 +440,27 @@ export default function ReleasesPage() { } function StatusBadge({ status, yankedReason }: { status: string; yankedReason?: string }) { + const { t } = useI18n() const cls = status === "published" ? "bg-emerald-100 text-emerald-800" : status === "yanked" ? "bg-red-100 text-red-800" : "bg-amber-100 text-amber-800" + + const label = + status === "published" + ? t("releases.statusPublished") + : status === "yanked" + ? t("releases.statusYanked") + : status === "draft" + ? t("releases.statusDraft") + : status + return ( {status === "yanked" && } - {status} + {label} ) } @@ -466,6 +476,7 @@ function CreateReleaseDialog({ onClose: () => void onCreated: (rel: Release) => void }) { + const { t } = useI18n() const qc = useQueryClient() const [productId, setProductId] = useState(products[0]?.id || "") const [version, setVersion] = useState("") @@ -485,7 +496,7 @@ function CreateReleaseDialog({ }), onSuccess: (rel) => { qc.invalidateQueries({ queryKey: ["admin", "releases"] }) - showToast(`Draft ${rel.version} created. Add artifacts to publish.`, "success") + showToast(t("releases.draftCreatedToast", { version: rel.version }), "success") onCreated(rel) }, onError: (e: Error) => setError(e.message), @@ -495,14 +506,12 @@ function CreateReleaseDialog({ - New release - - Create the release record. You'll add platform-specific binaries (artifacts) in the next step. - + {t("releases.createTitle")} + {t("releases.createDesc")}
- + setVersion(e.target.value)} />
- + setName(e.target.value)} /> + + setName(e.target.value)} + />
- +