heymoosh_ticktick-quick-add.../settings.ts
Claude 0c25564337
Normalize redirect URI to fix OAuth invalid_grant (1.2.3)
TickTick's OAuth server requires byte-exact match between the redirect_uri
the plugin sends and the value registered in the developer portal. Users
who register the URI with a trailing slash (common on iOS paste, or by
hand) get rejected with:

  error="invalid_grant"
  error_description="Invalid redirect: ...vercel.app does not match one
  of the registered values: [...vercel.app/]"

(Reported in issue #2.)

Normalize the redirect URI by stripping trailing slashes and whitespace at
all four use/save sites:

- main.ts: startAuthFlow() and exchangeAuthCodeForToken() clean the URI
  before sending — protects existing users with a slash already saved
- settings.ts: both Redirect URI inputs strip on save so the persisted
  value matches what's displayed
- settings.ts: both descriptions now warn against trailing slashes
- README: new Troubleshooting entry for the invalid_grant error

https://claude.ai/code/session_01V2fsiatACzjUCNwSEhwER9
2026-06-05 16:16:26 +00:00

270 lines
11 KiB
TypeScript

import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import TickTickPlugin from './main';
export interface TickTickSettings {
accessToken: string;
refreshToken?: string;
tokenExpiry?: number;
clientId: string;
clientSecret: string;
redirectUri?: string;
tempCodeVerifier?: string;
tempState?: string;
selectionMode: 'line' | 'paragraph';
tagPosition: 'append' | 'prepend';
}
export const DEFAULT_SETTINGS: TickTickSettings = {
accessToken: '',
clientId: '',
clientSecret: '',
redirectUri: 'https://ticktick-quick-add-obsidian-6yawfmvnj-mooshs-projects-0635287d.vercel.app',
selectionMode: 'line',
tagPosition: 'append'
};
export class TickTickSettingTab extends PluginSettingTab {
plugin: TickTickPlugin;
constructor(app: App, plugin: TickTickPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
if (this.plugin.settings.accessToken) {
this.renderConnectedView(containerEl);
} else {
this.renderSetupView(containerEl);
}
}
private renderSetupView(containerEl: HTMLElement): void {
containerEl.createEl('h2', { text: 'Set up TickTick' });
containerEl.createEl('p', {
text: 'Connect this plugin to your TickTick account in two steps. You only need to do this once.'
});
new Setting(containerEl)
.setName('Step 1 — Enter your TickTick app credentials')
.setHeading();
const step1 = containerEl.createEl('p');
step1.appendText('Sign in to the ');
const portalLink = step1.createEl('a', {
text: 'TickTick Developer Portal',
href: 'https://developer.ticktick.com/'
});
portalLink.setAttr('target', '_blank');
portalLink.setAttr('rel', 'noopener');
step1.appendText(', create an app, and copy its Client ID and Client Secret into the fields below. In the portal, set the app\'s Redirect URI to the value shown in the Redirect URI field.');
new Setting(containerEl)
.setName('Client ID')
.addText(text =>
text
.setPlaceholder('Your Client ID')
.setValue(this.plugin.settings.clientId || '')
.onChange(async (value) => {
this.plugin.settings.clientId = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Client Secret')
.addText(text => {
text.inputEl.type = 'password';
text.setPlaceholder('••••••••••')
.setValue(this.plugin.settings.clientSecret || '')
.onChange(async (value) => {
this.plugin.settings.clientSecret = value.trim();
await this.plugin.saveSettings();
});
return text;
});
new Setting(containerEl)
.setName('Redirect URI')
.setDesc('Copy this exact value into your TickTick app\'s Redirect URI field in the developer portal. Do not add a trailing slash — the URIs must match exactly. Only change it here if you self-host the callback page.')
.addText(text =>
text
.setPlaceholder(DEFAULT_SETTINGS.redirectUri!)
.setValue(this.plugin.settings.redirectUri || '')
.onChange(async (value) => {
this.plugin.settings.redirectUri = value.trim().replace(/\/+$/, '');
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Step 2 — Authorize')
.setHeading();
containerEl.createEl('p', {
text: 'Tap Connect, then tap the link in the popup to sign in to TickTick and approve access. The callback page will either return you to Obsidian automatically, or show you an authorization code — if you see a code, copy it and paste it in the field below.'
});
new Setting(containerEl)
.setName('Connect to TickTick')
.addButton(button => {
button.setButtonText('Connect').setCta().onClick(async () => {
await this.plugin.startAuthFlow();
});
});
new Setting(containerEl)
.setName('Authorization code')
.setDesc('Only needed if the callback page showed you a code instead of returning you to Obsidian automatically.')
.addText(text =>
text
.setPlaceholder('Paste authorization code')
.onChange(async (code) => {
if (code.trim()) {
await this.plugin.exchangeAuthCodeForToken(code.trim());
text.setValue('');
this.display();
}
})
);
}
private renderConnectedView(containerEl: HTMLElement): void {
containerEl.createEl('h2', { text: 'Connected to TickTick' });
containerEl.createEl('p', {
text: 'Use the "Create TickTick task" command from any note to send the current line or paragraph to TickTick.'
});
new Setting(containerEl)
.setName('Plugin behavior')
.setHeading();
new Setting(containerEl)
.setName('Selection mode')
.setDesc('Capture only the current line or the entire paragraph.')
.addDropdown(dropdown =>
dropdown
.addOption('line', 'Current line')
.addOption('paragraph', 'Entire paragraph')
.setValue(this.plugin.settings.selectionMode)
.onChange(async (value: string) => {
this.plugin.settings.selectionMode = value as 'line' | 'paragraph';
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Tag position')
.setDesc('Where the #ticktick tag is added relative to the text.')
.addDropdown(dropdown =>
dropdown
.addOption('append', 'Append (end)')
.addOption('prepend', 'Prepend (beginning)')
.setValue(this.plugin.settings.tagPosition)
.onChange(async (value: string) => {
this.plugin.settings.tagPosition = value as 'append' | 'prepend';
await this.plugin.saveSettings();
})
);
const details = containerEl.createEl('details', { cls: 'ticktick-advanced' });
details.style.marginTop = '2em';
const summary = details.createEl('summary', { text: 'Advanced — Connection details' });
summary.style.cursor = 'pointer';
summary.style.fontWeight = '600';
summary.style.padding = '0.5em 0';
new Setting(details)
.setName('Reconnect')
.setDesc('Re-run the OAuth flow. Use if your token can no longer be refreshed.')
.addButton(button => {
button.setButtonText('Reconnect').onClick(async () => {
await this.plugin.startAuthFlow();
});
});
new Setting(details)
.setName('Authorization code')
.setDesc('Manual fallback if reconnecting did not return you to Obsidian automatically.')
.addText(text =>
text
.setPlaceholder('Paste authorization code')
.onChange(async (code) => {
if (code.trim()) {
await this.plugin.exchangeAuthCodeForToken(code.trim());
text.setValue('');
this.display();
}
})
);
new Setting(details)
.setName('Disconnect')
.setDesc('Clear stored tokens. You will need to authorize again to create tasks.')
.addButton(button => {
button.setButtonText('Disconnect').setWarning().onClick(async () => {
this.plugin.settings.accessToken = '';
this.plugin.settings.refreshToken = undefined;
this.plugin.settings.tokenExpiry = undefined;
await this.plugin.saveSettings();
new Notice('Disconnected from TickTick.');
this.display();
});
});
new Setting(details)
.setName('Client ID')
.addText(text =>
text
.setValue(this.plugin.settings.clientId || '')
.onChange(async (value) => {
this.plugin.settings.clientId = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName('Client Secret')
.addText(text => {
text.inputEl.type = 'password';
text.setPlaceholder('••••••••••')
.setValue(this.plugin.settings.clientSecret || '')
.onChange(async (value) => {
this.plugin.settings.clientSecret = value.trim();
await this.plugin.saveSettings();
});
return text;
});
new Setting(details)
.setName('Redirect URI')
.setDesc('Must match the value registered in your TickTick app exactly, with no trailing slash.')
.addText(text =>
text
.setValue(this.plugin.settings.redirectUri || '')
.onChange(async (value) => {
this.plugin.settings.redirectUri = value.trim().replace(/\/+$/, '');
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName('Access token')
.setDesc('Stored access token (read-only).')
.addText(text => {
text.inputEl.readOnly = true;
text.setValue(this.plugin.settings.accessToken || '');
});
new Setting(details)
.setName('Refresh token')
.setDesc('Stored refresh token (read-only).')
.addText(text => {
text.inputEl.readOnly = true;
text.setValue(this.plugin.settings.refreshToken || '');
});
}
}