add kerio format support
This commit is contained in:
@@ -10,3 +10,4 @@
|
|||||||
# local cache of the impeccable design hook
|
# local cache of the impeccable design hook
|
||||||
.impeccable/
|
.impeccable/
|
||||||
**/.impeccable/
|
**/.impeccable/
|
||||||
|
*.csv
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package csvimport
|
package csvimport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,3 +54,110 @@ func Parse(r io.Reader) ([]Row, error) {
|
|||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kerio Connect exports users as a semicolon-separated file whose first line is
|
||||||
|
// a header. Only these columns matter; the rest (quotas, last login, …) is
|
||||||
|
// ignored. The file carries no domain — the caller supplies it.
|
||||||
|
const (
|
||||||
|
kerioColName = 0 // login without the domain part
|
||||||
|
kerioColDescription = 2 // Kerio keeps the plaintext password here
|
||||||
|
kerioColEnable = 3 // "Yes" / "No"
|
||||||
|
kerioMinColumns = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainRe accepts a bare DNS domain: labels of alphanumerics/hyphens with at
|
||||||
|
// least one dot and no scheme, user part, or whitespace.
|
||||||
|
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
||||||
|
|
||||||
|
func normalizeDomain(domain string) (string, error) {
|
||||||
|
d := strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
if d == "" {
|
||||||
|
return "", fmt.Errorf("domain is required")
|
||||||
|
}
|
||||||
|
if !domainRe.MatchString(d) {
|
||||||
|
return "", fmt.Errorf("invalid domain %q", domain)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipBOM consumes a leading UTF-8 byte-order mark, which Kerio writes into its
|
||||||
|
// exports and encoding/csv would otherwise glue onto the first header field.
|
||||||
|
func skipBOM(br *bufio.Reader) error {
|
||||||
|
b, err := br.Peek(3)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(b) == 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||||
|
_, _ = br.Discard(3)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseKerio reads a Kerio Connect user export and maps every enabled, non-admin
|
||||||
|
// account onto both sides of a migration: the login and password are identical
|
||||||
|
// on source and destination, only the server differs. Rows for disabled accounts
|
||||||
|
// and the built-in admin are skipped.
|
||||||
|
func ParseKerio(r io.Reader, domain string) ([]Row, error) {
|
||||||
|
d, err := normalizeDomain(domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(r)
|
||||||
|
if err := skipBOM(br); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cr := csv.NewReader(br)
|
||||||
|
cr.Comma = ';'
|
||||||
|
cr.FieldsPerRecord = -1 // проверяем сами
|
||||||
|
cr.LazyQuotes = true // FullName нередко содержит одиночную кавычку
|
||||||
|
|
||||||
|
header, err := cr.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read header: %w", err)
|
||||||
|
}
|
||||||
|
if len(header) < kerioMinColumns || !strings.EqualFold(strings.TrimSpace(header[kerioColName]), "Name") {
|
||||||
|
return nil, fmt.Errorf("not a Kerio export: expected a header starting with Name;FullName;Description;Enable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []Row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for {
|
||||||
|
rec, err := cr.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
line, _ := cr.FieldPos(0)
|
||||||
|
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(rec) < kerioMinColumns {
|
||||||
|
return nil, fmt.Errorf("line %d: expected at least %d columns, got %d", line, kerioMinColumns, len(rec))
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(rec[kerioColName]))
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("line %d: Name is empty", line)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(rec[kerioColEnable]), "Yes") || name == "admin" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pass := strings.TrimSpace(rec[kerioColDescription])
|
||||||
|
if pass == "" {
|
||||||
|
return nil, fmt.Errorf("line %d: no password in the Description column for %q", line, name)
|
||||||
|
}
|
||||||
|
if seen[name] {
|
||||||
|
return nil, fmt.Errorf("line %d: duplicate Name %q", line, name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
login := name + "@" + d
|
||||||
|
rows = append(rows, Row{SrcLogin: login, SrcPass: pass, DstLogin: login, DstPass: pass})
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("no enabled accounts found in the export")
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,100 @@ func TestParseZeroRowsErrors(t *testing.T) {
|
|||||||
t.Fatal("expected error when no rows parsed")
|
t.Fatal("expected error when no rows parsed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kerioHeader = "Name;FullName;Description;Enable;DataSource;Authentication;Role;Groups;MailAddress\n"
|
||||||
|
|
||||||
|
func TestParseKerioOK(t *testing.T) {
|
||||||
|
in := kerioHeader +
|
||||||
|
"j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n" +
|
||||||
|
"k.smith;Kim Smith;SrcPass22;Yes;Internal;Internal;No rights;all;k.smith\n"
|
||||||
|
rows, err := ParseKerio(strings.NewReader(in), "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
want := Row{
|
||||||
|
SrcLogin: "j.doe@example.test", SrcPass: "SrcPass11",
|
||||||
|
DstLogin: "j.doe@example.test", DstPass: "SrcPass11",
|
||||||
|
}
|
||||||
|
if rows[0] != want {
|
||||||
|
t.Fatalf("row 0: got %+v, want %+v", rows[0], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioSkipsDisabledAndAdmin(t *testing.T) {
|
||||||
|
in := kerioHeader +
|
||||||
|
"admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n" +
|
||||||
|
"o.disabled;;OffPass111;No;Internal;Internal;No rights;all;o.disabled\n" +
|
||||||
|
"info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
rows, err := ParseKerio(strings.NewReader(in), "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].SrcLogin != "info@example.test" {
|
||||||
|
t.Fatalf("only the enabled non-admin row must survive, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioStripsBOM(t *testing.T) {
|
||||||
|
in := "\ufeff" + kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
rows, err := ParseKerio(strings.NewReader(in), "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("want 1 row, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioRejectsMissingHeader(t *testing.T) {
|
||||||
|
in := "j.doe;Jane Doe;SrcPass11;Yes;Internal;Internal;No rights;all;j.doe\n"
|
||||||
|
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
|
||||||
|
t.Fatal("a file without the Kerio header must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioRejectsBadDomain(t *testing.T) {
|
||||||
|
in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
for _, domain := range []string{"", " ", "@example.test", "exam ple.test", "example", "info@example.test"} {
|
||||||
|
if _, err := ParseKerio(strings.NewReader(in), domain); err == nil {
|
||||||
|
t.Fatalf("domain %q must be rejected", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioTrimsAndLowercasesDomain(t *testing.T) {
|
||||||
|
in := kerioHeader + "info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
rows, err := ParseKerio(strings.NewReader(in), " Example.TEST ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if rows[0].SrcLogin != "info@example.test" {
|
||||||
|
t.Fatalf("domain must be trimmed and lowercased, got %q", rows[0].SrcLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioRejectsEmptyPassword(t *testing.T) {
|
||||||
|
in := kerioHeader + "info;;;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
|
||||||
|
t.Fatal("an enabled account without a password must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioRejectsDuplicateName(t *testing.T) {
|
||||||
|
in := kerioHeader +
|
||||||
|
"info;;InfoPass11;Yes;Internal;Internal;No rights;all;info\n" +
|
||||||
|
"info;;aaaaaaAa1;Yes;Internal;Internal;No rights;all;info\n"
|
||||||
|
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
|
||||||
|
t.Fatal("duplicate Name must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKerioZeroRowsErrors(t *testing.T) {
|
||||||
|
in := kerioHeader + "admin;;AdminPass1;Yes;Internal;Internal;Account admin;;admin\n"
|
||||||
|
if _, err := ParseKerio(strings.NewReader(in), "example.test"); err == nil {
|
||||||
|
t.Fatal("expected error when every row is filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-1
@@ -14,6 +14,17 @@ import (
|
|||||||
"github.com/vasyansk/imap-copier/internal/store"
|
"github.com/vasyansk/imap-copier/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// parseImportRows picks the CSV dialect from the "format" form field. A Kerio
|
||||||
|
// Connect export holds one login/password pair and no domain, so the operator
|
||||||
|
// supplies the domain alongside the file; anything else is the plain 4-column
|
||||||
|
// src/dst format.
|
||||||
|
func parseImportRows(r *http.Request, file io.Reader) ([]csvimport.Row, error) {
|
||||||
|
if r.FormValue("format") == "kerio" {
|
||||||
|
return csvimport.ParseKerio(file, r.FormValue("domain"))
|
||||||
|
}
|
||||||
|
return csvimport.Parse(file)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
taskID, err := pathID(r, "id")
|
taskID, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,7 +37,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
rows, err := csvimport.Parse(file)
|
rows, err := parseImportRows(r, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -27,6 +29,60 @@ func TestImportCSVFailsOnBadEncKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importReq builds a multipart import request with the given CSV payload and
|
||||||
|
// extra form fields, then returns it with the uploaded file ready to read.
|
||||||
|
func importReq(t *testing.T, csv string, fields map[string]string) (*http.Request, io.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
body := &strings.Builder{}
|
||||||
|
mw := multipart.NewWriter(body)
|
||||||
|
for k, v := range fields {
|
||||||
|
_ = mw.WriteField(k, v)
|
||||||
|
}
|
||||||
|
fw, _ := mw.CreateFormFile("file", "a.csv")
|
||||||
|
fw.Write([]byte(csv))
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/api/tasks/1/import", strings.NewReader(body.String()))
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.SetPathValue("id", "1")
|
||||||
|
file, _, err := req.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("form file: %v", err)
|
||||||
|
}
|
||||||
|
return req, file
|
||||||
|
}
|
||||||
|
|
||||||
|
const kerioCSV = "Name;FullName;Description;Enable;DataSource\n" +
|
||||||
|
"info;;InfoPass11;Yes;Internal\n"
|
||||||
|
|
||||||
|
func TestParseImportRowsUsesKerioParserWithDomain(t *testing.T) {
|
||||||
|
req, file := importReq(t, kerioCSV, map[string]string{"format": "kerio", "domain": "example.test"})
|
||||||
|
rows, err := parseImportRows(req, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].SrcLogin != "info@example.test" || rows[0].DstLogin != "info@example.test" {
|
||||||
|
t.Fatalf("kerio rows must carry the supplied domain on both sides, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportRowsKerioRequiresDomain(t *testing.T) {
|
||||||
|
req, file := importReq(t, kerioCSV, map[string]string{"format": "kerio"})
|
||||||
|
if _, err := parseImportRows(req, file); err == nil {
|
||||||
|
t.Fatal("kerio import without a domain must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImportRowsDefaultsToPlainFormat(t *testing.T) {
|
||||||
|
req, file := importReq(t, "a@x,p1,a@y,p2\n", nil)
|
||||||
|
rows, err := parseImportRows(req, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].SrcLogin != "a@x" || rows[0].DstPass != "p2" {
|
||||||
|
t.Fatalf("no format field must keep the 4-column parser, got %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunAccountIDs(t *testing.T) {
|
func TestParseRunAccountIDs(t *testing.T) {
|
||||||
// empty body => nil (run all)
|
// empty body => nil (run all)
|
||||||
req := httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(""))
|
req := httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(""))
|
||||||
|
|||||||
@@ -177,3 +177,13 @@ export const importCSV = (id: number, file: File) => {
|
|||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Kerio Connect export carries no domain, so the operator supplies it here;
|
||||||
|
// the login and password apply to both sides of the migration.
|
||||||
|
export const importKerioCSV = (id: number, file: File, domain: string) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('format', 'kerio')
|
||||||
|
fd.append('domain', domain)
|
||||||
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
|
||||||
|
// A bare DNS domain: no scheme, no user part, no whitespace. Mirrors the
|
||||||
|
// server-side check in csvimport.normalizeDomain so bad input is caught here.
|
||||||
|
const domainRe = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
busy: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (file: File, domain: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KerioImportModal({ open, busy, onClose, onSubmit }: Props) {
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setDomain('')
|
||||||
|
setFile(null)
|
||||||
|
setError(null)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const d = domain.trim().toLowerCase()
|
||||||
|
if (!domainRe.test(d)) {
|
||||||
|
setError('Enter a bare domain, e.g. galaxyhotel.kz')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
setError('Choose the Kerio export file')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
onSubmit(file, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="Import from Kerio" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<p className="map-hint">
|
||||||
|
The Kerio user export lists a login and its password but no domain. The domain you enter is appended to every
|
||||||
|
login and used for both the source and the destination. Disabled accounts and <code>admin</code> are skipped.
|
||||||
|
</p>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="kerio_domain">Mail domain</label>
|
||||||
|
<input
|
||||||
|
id="kerio_domain"
|
||||||
|
data-modal-autofocus
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
placeholder="galaxyhotel.kz"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="kerio_file">Export file</label>
|
||||||
|
<input
|
||||||
|
id="kerio_file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Importing…' : 'Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||||
import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
||||||
import { connectTaskWS, type TaskEvent } from '../ws'
|
import { connectTaskWS, type TaskEvent } from '../ws'
|
||||||
import { StatusBadge } from '../components/StatusBadge'
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
import { useConfirm } from '../components/ConfirmProvider'
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
import { FolderMappingModal } from '../components/FolderMappingModal'
|
import { FolderMappingModal } from '../components/FolderMappingModal'
|
||||||
import { RunLogModal } from '../components/RunLogModal'
|
import { RunLogModal } from '../components/RunLogModal'
|
||||||
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
||||||
|
import { KerioImportModal } from '../components/KerioImportModal'
|
||||||
|
|
||||||
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
||||||
const [showRuns, setShowRuns] = useState(false)
|
const [showRuns, setShowRuns] = useState(false)
|
||||||
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
||||||
|
const [kerioOpen, setKerioOpen] = useState(false)
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -288,6 +290,20 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onKerioImport(file: File, domain: string) {
|
||||||
|
setBusy('import')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await importKerioCSV(id, file, domain)
|
||||||
|
setKerioOpen(false)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Kerio import failed')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onDeleteAccount(accId: number, login: string) {
|
async function onDeleteAccount(accId: number, login: string) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Remove account',
|
title: 'Remove account',
|
||||||
@@ -544,6 +560,9 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
||||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
|
||||||
|
Import from Kerio
|
||||||
|
</button>
|
||||||
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
||||||
download example.csv
|
download example.csv
|
||||||
</button>
|
</button>
|
||||||
@@ -768,6 +787,12 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
)}
|
)}
|
||||||
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
||||||
<AccountErrorsModal taskId={id} account={errorsFor} onClose={() => setErrorsFor(null)} />
|
<AccountErrorsModal taskId={id} account={errorsFor} onClose={() => setErrorsFor(null)} />
|
||||||
|
<KerioImportModal
|
||||||
|
open={kerioOpen}
|
||||||
|
busy={busy === 'import'}
|
||||||
|
onClose={() => setKerioOpen(false)}
|
||||||
|
onSubmit={onKerioImport}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user