Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,31 @@ kronan status # Check if token is valid
kronan logout # Clear token
```

Tokens are stored in `~/.kronan/token`.
Tokens are stored in `~/.kronan/token`. Profile data is stored in `~/.kronan/profiles.json`.

**Note:** You must have a Krónan account with Auðkenni (Icelandic e-ID) login to create access tokens.

### Multiple profiles

If you have access to multiple Krónan accounts (e.g., personal and a shared family account), you can save tokens as named profiles and switch between them:

```bash
kronan token <personal-token> --name personal
kronan token <family-token> --name family

kronan profiles # List saved profiles
kronan profile family # Switch to family profile
kronan profile personal # Switch back
kronan profile remove family # Remove a profile
```

## Usage

```
kronan token <token> Save access token
kronan token <token> --name <name> Save as named profile
kronan profiles List saved profiles
kronan profile <name> Switch active profile
kronan status Check authentication status
kronan logout Clear stored token

Expand Down
113 changes: 99 additions & 14 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ import { join } from "node:path";
// Token storage path
const TOKEN_DIR = join(homedir(), ".kronan");
const TOKEN_FILE = join(TOKEN_DIR, "token");
const PROFILES_FILE = join(TOKEN_DIR, "profiles.json");

export interface AuthToken {
token: string;
}

export interface Profiles {
active?: string;
profiles: Record<string, string>;
}

/**
* Save access token to disk.
*/
Expand Down Expand Up @@ -47,20 +53,6 @@ export async function loadToken(): Promise<AuthToken | null> {
}
}

/**
* Ensure we have a valid token.
* Throws if no token available.
*/
export async function requireAuth(): Promise<AuthToken> {
const token = await loadToken();
if (!token) {
throw new Error(
"No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token <your-token>'",
);
}
return token;
}

/**
* Clear stored token (logout).
*/
Expand All @@ -78,3 +70,96 @@ export async function clearToken(): Promise<void> {
export function getAuthHeader(token: AuthToken): string {
return `AccessToken ${token.token}`;
}

// --- Profile Management ---

/**
* Load profiles from disk.
*/
export async function loadProfiles(): Promise<Profiles> {
const file = Bun.file(PROFILES_FILE);

if (!(await file.exists())) {
return { profiles: {} };
}

try {
return await file.json();
} catch {
return { profiles: {} };
}
}

/**
* Save profiles to disk.
*/
export async function saveProfiles(profiles: Profiles): Promise<void> {
const { mkdir } = await import("node:fs/promises");
await mkdir(TOKEN_DIR, { recursive: true });
await Bun.write(PROFILES_FILE, JSON.stringify(profiles, null, 2));
}

/**
* Save a named profile.
*/
export async function saveProfile(
name: string,
tokenValue: string,
): Promise<void> {
const profiles = await loadProfiles();
profiles.profiles[name] = tokenValue;
// If this is the first profile, make it active
if (!profiles.active) {
profiles.active = name;
}
await saveProfiles(profiles);
}

/**
* Remove a named profile.
*/
export async function removeProfile(name: string): Promise<void> {
const profiles = await loadProfiles();
delete profiles.profiles[name];
if (profiles.active === name) {
const remaining = Object.keys(profiles.profiles);
profiles.active = remaining.length > 0 ? remaining[0] : undefined;
}
await saveProfiles(profiles);
}

/**
* Set the active profile.
*/
export async function setActiveProfile(name: string): Promise<void> {
const profiles = await loadProfiles();
if (!profiles.profiles[name]) {
throw new Error(
`Profile "${name}" not found. Use 'kronan profiles' to see available profiles.`,
);
}
profiles.active = name;
await saveProfiles(profiles);
// Also write to the legacy token file for backward compat
await saveToken(profiles.profiles[name]!);
}

/**
* Ensure we have a valid token.
* Priority: active profile > legacy token file.
*/
export async function requireAuth(): Promise<AuthToken> {
// Check profiles first
const profiles = await loadProfiles();
if (profiles.active && profiles.profiles[profiles.active]) {
return { token: profiles.profiles[profiles.active]! };
}
// Fall back to legacy token file
const token = await loadToken();
if (!token) {
throw new Error(
"No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token <your-token>'",
);
}
return token;
}
58 changes: 54 additions & 4 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,75 @@
*/

import { getMe } from "../api.ts";
import { clearToken, loadToken, saveToken } from "../auth.ts";
import {
clearToken,
loadProfiles,
loadToken,
removeProfile,
saveProfile,
saveToken,
setActiveProfile,
} from "../auth.ts";

export async function tokenCommand(tokenValue: string): Promise<void> {
// First validate the token before saving
export async function tokenCommand(
tokenValue: string,
options: { name?: string } = {},
): Promise<void> {
// Validate the token before saving
const token = { token: tokenValue };

try {
const me = await getMe(token);
// Token is valid, save it
const profileName = options.name || me.name;

// Save to profiles and legacy token file
await saveProfile(profileName, tokenValue);
await saveToken(tokenValue);

console.log("Token saved successfully!");
console.log("");
console.log(` Profile: ${profileName}`);
console.log(` Identity: ${me.name}`);
console.log(` Type: ${me.type}`);
} catch (error: any) {
throw new Error(`Token validation failed: ${error.message}`);
}
}

export async function profilesCommand(): Promise<void> {
const profiles = await loadProfiles();
const names = Object.keys(profiles.profiles);

if (names.length === 0) {
console.log("No profiles saved. Use 'kronan token <token>' to add one.");
return;
}

console.log("Profiles:\n");
for (const name of names) {
const active = profiles.active === name ? " (active)" : "";
console.log(` ${name}${active}`);
}
console.log("\nUse 'kronan profile <name>' to switch profiles.");
}

export async function profileSwitchCommand(name: string): Promise<void> {
await setActiveProfile(name);
console.log(`Switched to profile "${name}".`);
}

export async function profileRemoveCommand(name: string): Promise<void> {
const profiles = await loadProfiles();
if (!profiles.profiles[name]) {
console.error(
`Profile "${name}" not found. Use 'kronan profiles' to see available profiles.`,
);
process.exit(1);
}
await removeProfile(name);
console.log(`Removed profile "${name}".`);
}

export async function logoutCommand(): Promise<void> {
await clearToken();
console.log("Token cleared.");
Expand Down
45 changes: 40 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*
* Usage:
* kronan token <token> Save access token (create at https://kronan.is/adgangur/adgangslyklar)
* kronan profiles List saved profiles
* kronan profile <name> Switch active profile
* kronan logout Clear stored token
* kronan status Show login status
* kronan search <query> Search for products
Expand Down Expand Up @@ -38,6 +40,9 @@ import {
} from "./commands/categories.ts";
import {
logoutCommand,
profileRemoveCommand,
profileSwitchCommand,
profilesCommand,
statusCommand,
tokenCommand,
} from "./commands/login.ts";
Expand Down Expand Up @@ -94,14 +99,14 @@ async function main() {
case "token": {
const tokenValue = args[1];
if (!tokenValue) {
console.error("Usage: kronan token <access-token>");
console.error("Usage: kronan token <access-token> [--name <profile-name>]");
console.error("");
console.error(
"Create an access token at: https://kronan.is/adgangur/adgangslyklar",
);
process.exit(1);
}
await tokenCommand(tokenValue);
await tokenCommand(tokenValue, { name: getFlag("name") });
break;
}

Expand All @@ -113,6 +118,27 @@ async function main() {
await statusCommand();
break;

case "profiles":
await profilesCommand();
break;

case "profile": {
const subcommand = args[1];
if (!subcommand) {
await profilesCommand();
} else if (subcommand === "remove") {
const name = args[2];
if (!name) {
console.error("Usage: kronan profile remove <name>");
process.exit(1);
}
await profileRemoveCommand(name);
} else {
await profileSwitchCommand(subcommand);
}
break;
}

case "search": {
const query = args[1];
if (!query) {
Expand Down Expand Up @@ -512,8 +538,7 @@ async function main() {
break;
}

case "me":
case "profile": {
case "me": {
const token = await requireAuth();
const me = await getMe(token);
if (jsonOutput) {
Expand Down Expand Up @@ -552,9 +577,15 @@ function printHelp() {

Authentication:
token <token> Save access token (create at https://kronan.is/adgangur/adgangslyklar)
token <token> --name <name> Save token as a named profile
logout Clear stored token
status Show login status

Profiles:
profiles List saved profiles
profile <name> Switch active profile
profile remove <name> Remove a profile

Products & Search:
search <query> Search for products
product <sku> Get product details by SKU
Expand Down Expand Up @@ -613,11 +644,15 @@ Flags:
--description <text> Description for lists create
--text <text> Text for notes
--sku <sku> SKU for notes
--name <name> Profile name for token command
--force Force destructive operations
--include-ignored Include ignored items in stats

Examples:
kronan token abc123def456
kronan token abc123 --name personal
kronan token def456 --name family
kronan profiles
kronan profile family
kronan search "mjólk"
kronan search "epli" --json --limit 5
kronan product 02500188
Expand Down
Loading