feat(esi): improve ESI SSO login flow and system name resolution
This commit introduces several improvements to the ESI (EVE Server Interface) Single Sign-On (SSO) flow and system name resolution: **ESI SSO Login Flow:** - **Asynchronous Callback Server:** The `StartCallbackServer` function is now deprecated in favor of `StartCallbackServerAsync`. This allows the callback server to run in the background without blocking the main application thread, improving responsiveness. - **Improved Login Status Polling:** After initiating the ESI login, the frontend now polls the `ESILoggedIn` status for a short period. This ensures that the UI reflects the login status more accurately and promptly after the user completes the authentication flow in their browser. - **Error Handling:** Added more specific error messages for failed token exchanges and invalid SSO responses. **System Name Resolution:** - **Multi-stage Resolution:** The `ResolveSystemIDByName` function now employs a more robust, multi-stage approach to find system IDs: 1. It first attempts to use the `universe/ids` endpoint for direct name-to-ID mapping, which is generally more accurate. 2. If that fails, it falls back to a `strict` search via the `search` endpoint. 3. As a final fallback, it performs a non-strict search and then resolves the names of the returned IDs to find an exact case-insensitive match. If no exact match is found, it returns the first result. - **Logging:** Added more detailed logging for each stage of the system name resolution process, aiding in debugging. - **ESI API Headers:** Ensured that necessary headers like `Accept` and `X-User-Agent` are correctly set for ESI API requests. **Frontend Changes:** - **Import `ESILoggedIn`:** The `ESILoggedIn` function is now imported into the `Header.tsx` component. - **Updated Toast Message:** The toast message for setting a destination now includes the system name for better context in case of errors.
This commit is contained in:
12
app.go
12
app.go
@@ -51,7 +51,9 @@ func (a *App) StartESILogin() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
go func() { _ = a.ssi.StartCallbackServer() }()
|
||||
if err := a.ssi.StartCallbackServerAsync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
runtime.BrowserOpenURL(a.ctx, url)
|
||||
return url, nil
|
||||
}
|
||||
@@ -68,6 +70,14 @@ func (a *App) ESILoginStatus() string {
|
||||
return "not logged in"
|
||||
}
|
||||
|
||||
// ESILoggedIn returns true if a valid access token is present
|
||||
func (a *App) ESILoggedIn() bool {
|
||||
if a.ssi == nil {
|
||||
return false
|
||||
}
|
||||
return a.ssi.Status().LoggedIn
|
||||
}
|
||||
|
||||
// SetDestination posts a waypoint to ESI to set destination
|
||||
func (a *App) SetDestination(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
||||
if a.ssi == nil {
|
||||
|
192
esi_sso.go
192
esi_sso.go
@@ -84,8 +84,62 @@ func (s *ESISSO) BuildAuthorizeURL() (string, error) {
|
||||
return issuerAuthorizeURL + "?" + q.Encode(), nil
|
||||
}
|
||||
|
||||
// StartCallbackServer starts a temporary local HTTP server to receive the SSO callback
|
||||
func (s *ESISSO) StartCallbackServer() error {
|
||||
// StartCallbackServerAsync starts the callback server in the background and returns immediately
|
||||
func (s *ESISSO) StartCallbackServerAsync() error {
|
||||
u, err := url.Parse(s.redirectURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return errors.New("redirect URI must be http(s)")
|
||||
}
|
||||
hostPort := u.Host
|
||||
if !strings.Contains(hostPort, ":") {
|
||||
if u.Scheme == "https" {
|
||||
hostPort += ":443"
|
||||
} else {
|
||||
hostPort += ":80"
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
code := q.Get("code")
|
||||
st := q.Get("state")
|
||||
if code == "" || st == "" || st != s.state {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("Invalid SSO response"))
|
||||
return
|
||||
}
|
||||
if err := s.exchangeToken(r.Context(), code); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "Login successful. You can close this window.")
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = s.server.Shutdown(context.Background())
|
||||
}()
|
||||
})
|
||||
|
||||
ln, err := net.Listen("tcp", hostPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.server = &http.Server{Handler: mux}
|
||||
go func() { _ = s.server.Serve(ln) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: blocking start; prefer StartCallbackServerAsync
|
||||
func (s *ESISSO) StartCallbackServer() error {
|
||||
u, err := url.Parse(s.redirectURI)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -93,10 +147,8 @@ func (s *ESISSO) StartCallbackServer() error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return errors.New("redirect URI must be http(s)")
|
||||
}
|
||||
// Bind exact host:port
|
||||
hostPort := u.Host
|
||||
if !strings.Contains(hostPort, ":") {
|
||||
// default ports
|
||||
if u.Scheme == "https" {
|
||||
hostPort += ":443"
|
||||
} else {
|
||||
@@ -106,7 +158,6 @@ func (s *ESISSO) StartCallbackServer() error {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||
// Receive code
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -119,7 +170,6 @@ func (s *ESISSO) StartCallbackServer() error {
|
||||
_, _ = w.Write([]byte("Invalid SSO response"))
|
||||
return
|
||||
}
|
||||
// Exchange token
|
||||
if err := s.exchangeToken(r.Context(), code); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
|
||||
@@ -127,7 +177,6 @@ func (s *ESISSO) StartCallbackServer() error {
|
||||
}
|
||||
_, _ = io.WriteString(w, "Login successful. You can close this window.")
|
||||
go func() {
|
||||
// stop shortly after responding
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = s.server.Shutdown(context.Background())
|
||||
}()
|
||||
@@ -262,14 +311,17 @@ func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginn
|
||||
q.Set("destination_id", strconv.FormatInt(destinationID, 10))
|
||||
q.Set("add_to_beginning", strconv.FormatBool(addToBeginning))
|
||||
q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers))
|
||||
endpoint := esiBase + "/v2/ui/autopilot/waypoint/" + "?" + q.Encode()
|
||||
q.Set("datasource", "tranquility")
|
||||
endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode()
|
||||
|
||||
fmt.Printf("ESI: POST waypoint dest=%d clear=%v addToBeginning=%v\n", destinationID, clearOthers, addToBeginning)
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-User-Agent", "signalerr/1.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -277,9 +329,11 @@ func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginn
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
fmt.Println("ESI: waypoint set OK", resp.Status)
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("ESI: waypoint failed %s body=%s\n", resp.Status, string(b))
|
||||
return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b))
|
||||
}
|
||||
|
||||
@@ -302,43 +356,139 @@ func (s *ESISSO) Status() SSOStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveSystemIDByName searches ESI for a solar system by exact name and returns its ID
|
||||
// ResolveSystemIDByName searches ESI for a solar system by name and returns its ID
|
||||
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, errors.New("empty system name")
|
||||
}
|
||||
// 1) Prefer universe/ids (name->id) for accuracy
|
||||
type idsReq struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
body, _ := json.Marshal(idsReq{Names: []string{name}})
|
||||
idsURL := esiBase + "/v3/universe/ids/?datasource=tranquility"
|
||||
fmt.Printf("ESI: resolve system id via universe/ids for name=%q\n", name)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, idsURL, strings.NewReader(string(body)))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, errDo := http.DefaultClient.Do(req)
|
||||
if errDo == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var idsResp struct {
|
||||
Systems []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"systems"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&idsResp); err == nil {
|
||||
if len(idsResp.Systems) > 0 {
|
||||
fmt.Printf("ESI: universe/ids hit: %s -> %d\n", idsResp.Systems[0].Name, idsResp.Systems[0].ID)
|
||||
return idsResp.Systems[0].ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: strict search
|
||||
q := url.Values{}
|
||||
q.Set("categories", "solar_system")
|
||||
q.Set("search", name)
|
||||
q.Set("strict", "true")
|
||||
endpoint := esiBase + "/v3/search/?" + q.Encode()
|
||||
searchURL := esiBase + "/v3/search/?" + q.Encode()
|
||||
fmt.Printf("ESI: strict search for %q\n", name)
|
||||
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req2.Header.Set("Accept", "application/json")
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err == nil {
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode == http.StatusOK {
|
||||
var payload struct {
|
||||
SolarSystem []int64 `json:"solar_system"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&payload); err == nil && len(payload.SolarSystem) > 0 {
|
||||
fmt.Printf("ESI: strict search hit: %d\n", payload.SolarSystem[0])
|
||||
return payload.SolarSystem[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
// 3) Fallback: non-strict search then best-effort name match via universe/names
|
||||
q.Set("strict", "false")
|
||||
searchURL2 := esiBase + "/v3/search/?" + q.Encode()
|
||||
fmt.Printf("ESI: non-strict search for %q\n", name)
|
||||
req3, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL2, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
req3.Header.Set("Accept", "application/json")
|
||||
resp3, err := http.DefaultClient.Do(req3)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return 0, fmt.Errorf("search failed: %s: %s", resp.Status, string(b))
|
||||
defer resp3.Body.Close()
|
||||
if resp3.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp3.Body)
|
||||
return 0, fmt.Errorf("search failed: %s: %s", resp3.Status, string(b))
|
||||
}
|
||||
var payload struct {
|
||||
SolarSystem []int64 `json:"solar_system"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload.SolarSystem) == 0 {
|
||||
if err := json.NewDecoder(resp3.Body).Decode(&payload); err != nil || len(payload.SolarSystem) == 0 {
|
||||
return 0, fmt.Errorf("system not found: %s", name)
|
||||
}
|
||||
// If one result, return it
|
||||
if len(payload.SolarSystem) == 1 {
|
||||
fmt.Printf("ESI: non-strict search single hit: %d\n", payload.SolarSystem[0])
|
||||
return payload.SolarSystem[0], nil
|
||||
}
|
||||
// Multiple: resolve names and pick exact case-insensitive match if possible
|
||||
ids := payload.SolarSystem
|
||||
var idNamesReq = make([]int64, 0, len(ids))
|
||||
idNamesReq = append(idNamesReq, ids...)
|
||||
|
||||
namesURL := esiBase + "/v3/universe/names/?datasource=tranquility"
|
||||
idsBody, _ := json.Marshal(idNamesReq)
|
||||
req4, err := http.NewRequestWithContext(ctx, http.MethodPost, namesURL, strings.NewReader(string(idsBody)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req4.Header.Set("Content-Type", "application/json")
|
||||
req4.Header.Set("Accept", "application/json")
|
||||
resp4, err := http.DefaultClient.Do(req4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp4.Body.Close()
|
||||
if resp4.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp4.Body)
|
||||
return 0, fmt.Errorf("names lookup failed: %s: %s", resp4.Status, string(b))
|
||||
}
|
||||
var namesResp []struct {
|
||||
Category string `json:"category"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp4.Body).Decode(&namesResp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
for _, n := range namesResp {
|
||||
if n.Category == "solar_system" && strings.ToLower(n.Name) == lower {
|
||||
fmt.Printf("ESI: names resolved exact: %s -> %d\n", n.Name, n.ID)
|
||||
return n.ID, nil
|
||||
}
|
||||
}
|
||||
// Fallback: return first
|
||||
fmt.Printf("ESI: names resolved fallback: returning %d for %q\n", namesResp[0].ID, name)
|
||||
return namesResp[0].ID, nil
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { StartESILogin, ESILoginStatus } from 'wailsjs/go/main/App';
|
||||
import { StartESILogin, ESILoginStatus, ESILoggedIn } from 'wailsjs/go/main/App';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
@@ -32,7 +32,17 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await StartESILogin();
|
||||
toast({ title: 'EVE Login', description: 'Complete login in your browser. Reopen menu to refresh status.' });
|
||||
toast({ title: 'EVE Login', description: 'Complete login in your browser.' });
|
||||
// Poll a few times to update status after redirect callback
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ok = await ESILoggedIn();
|
||||
if (ok) {
|
||||
const s = await ESILoginStatus();
|
||||
setStatus(s);
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Login failed', description: String(e), variant: 'destructive' });
|
||||
}
|
||||
|
@@ -41,7 +41,7 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
||||
toast({ title: 'Destination set', description: `${system.solarSystemName}` });
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' });
|
||||
toast({ title: 'Failed to set destination', description: `${system.solarSystemName}: ${String(e)}`, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -1,6 +1,8 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function ESILoggedIn():Promise<boolean>;
|
||||
|
||||
export function ESILoginStatus():Promise<string>;
|
||||
|
||||
export function Greet(arg1:string):Promise<string>;
|
||||
|
@@ -2,6 +2,10 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function ESILoggedIn() {
|
||||
return window['go']['main']['App']['ESILoggedIn']();
|
||||
}
|
||||
|
||||
export function ESILoginStatus() {
|
||||
return window['go']['main']['App']['ESILoginStatus']();
|
||||
}
|
||||
|
Reference in New Issue
Block a user