Browse Source

cmd/tsidp: add web UI for managing OIDC clients (#16068)

Add comprehensive web interface at ui for managing OIDC clients, similar to tsrecorder's design. Features include list view, create/edit forms with validation, client secret management, delete functionality with confirmation dialogs, responsive design, and restricted tailnet access only.

Fixes #16067

Signed-off-by: Raj Singh <[email protected]>
Raj Singh 9 months ago
parent
commit
09582bdc00
6 changed files with 1097 additions and 7 deletions
  1. 1 7
      cmd/tsidp/tsidp.go
  2. 199 0
      cmd/tsidp/ui-edit.html
  3. 53 0
      cmd/tsidp/ui-header.html
  4. 73 0
      cmd/tsidp/ui-list.html
  5. 446 0
      cmd/tsidp/ui-style.css
  6. 325 0
      cmd/tsidp/ui.go

+ 1 - 7
cmd/tsidp/tsidp.go

@@ -452,13 +452,7 @@ func (s *idpServer) newMux() *http.ServeMux {
 	mux.HandleFunc("/userinfo", s.serveUserInfo)
 	mux.HandleFunc("/userinfo", s.serveUserInfo)
 	mux.HandleFunc("/token", s.serveToken)
 	mux.HandleFunc("/token", s.serveToken)
 	mux.HandleFunc("/clients/", s.serveClients)
 	mux.HandleFunc("/clients/", s.serveClients)
-	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/" {
-			io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>")
-			return
-		}
-		http.Error(w, "tsidp: not found", http.StatusNotFound)
-	})
+	mux.HandleFunc("/", s.handleUI)
 	return mux
 	return mux
 }
 }
 
 

+ 199 - 0
cmd/tsidp/ui-edit.html

@@ -0,0 +1,199 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>
+      {{if .IsNew}}Add New Client{{else}}Edit Client{{end}} - Tailscale OIDC Identity Provider
+    </title>
+    <link rel="stylesheet" type="text/css" href="/style.css" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  </head>
+
+  <body>
+    {{template "header"}}
+
+    <main>
+      <div class="form-container">
+        <div class="form-header">
+          <h2>
+            {{if .IsNew}}Add New OIDC Client{{else}}Edit OIDC Client{{end}}
+          </h2>
+          <a href="/" class="btn btn-secondary">← Back to Clients</a>
+        </div>
+
+        {{if .Success}}
+        <div class="alert alert-success">
+          {{.Success}}
+        </div>
+        {{end}}
+
+        {{if .Error}}
+        <div class="alert alert-error">
+          {{.Error}}
+        </div>
+        {{end}}
+
+        {{if and .Secret .IsNew}}
+        <div class="client-info">
+          <h3>Client Created Successfully!</h3>
+          <p class="warning">⚠️ Save both the Client ID and Secret now! The secret will not be shown again.</p>
+          
+                    <div class="form-group">
+            <label>Client ID</label>
+            <div class="secret-field">
+              <input type="text" value="{{.ID}}" readonly class="secret-input" id="client-id">
+              <button type="button" onclick="copyClientId(event)" class="btn btn-secondary btn-small">Copy</button>
+            </div>
+          </div>
+          
+          <div class="form-group">
+            <label>Client Secret</label>
+            <div class="secret-field">
+              <input type="text" value="{{.Secret}}" readonly class="secret-input" id="client-secret">
+              <button type="button" onclick="copySecret(event)" class="btn btn-secondary btn-small">Copy</button>
+            </div>
+          </div>
+        </div>
+        {{end}}
+
+        {{if and .Secret .IsEdit}}
+        <div class="secret-display">
+          <h3>New Client Secret</h3>
+          <p class="warning">⚠️ Save this secret now! It will not be shown again.</p>
+          <div class="secret-field">
+            <input type="text" value="{{.Secret}}" readonly class="secret-input" id="client-secret">
+            <button type="button" onclick="copySecret(event)" class="btn btn-secondary btn-small">Copy</button>
+          </div>
+        </div>
+        {{end}}
+
+        <form method="POST" class="client-form">
+          <div class="form-group">
+            <label for="name">Client Name</label>
+            <input 
+              type="text" 
+              id="name" 
+              name="name" 
+              value="{{.Name}}" 
+              placeholder="e.g., My Application"
+              class="form-input"
+            >
+            <div class="form-help">
+              A descriptive name for this OIDC client (optional).
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="redirect_uri">Redirect URI <span class="required">*</span></label>
+            <input 
+              type="url" 
+              id="redirect_uri" 
+              name="redirect_uri" 
+              value="{{.RedirectURI}}" 
+              placeholder="https://example.com/auth/callback"
+              class="form-input"
+              required
+            >
+            <div class="form-help">
+              The URL where users will be redirected after authentication.
+            </div>
+          </div>
+
+          {{if .IsEdit}}
+          <div class="form-group">
+            <label>Client ID</label>
+            <input 
+              type="text" 
+              value="{{.ID}}" 
+              readonly 
+              class="form-input form-input-readonly"
+            >
+            <div class="form-help">
+              The client ID cannot be changed.
+            </div>
+          </div>
+          {{end}}
+
+          <div class="form-actions">
+            <button type="submit" class="btn btn-primary">
+              {{if .IsNew}}Create Client{{else}}Update Client{{end}}
+            </button>
+            
+            {{if .IsEdit}}
+            <button type="submit" name="action" value="regenerate_secret" class="btn btn-warning" 
+                    onclick="return confirm('Are you sure you want to regenerate the client secret? The old secret will stop working immediately.')">
+              Regenerate Secret
+            </button>
+            
+            <button type="submit" name="action" value="delete" class="btn btn-danger" 
+                    onclick="return confirm('Are you sure you want to delete this client? This cannot be undone.')">
+              Delete Client
+            </button>
+            {{end}}
+          </div>
+        </form>
+
+        {{if .IsEdit}}
+        <div class="client-info">
+          <h3>Client Information</h3>
+          <dl>
+            <dt>Client ID</dt>
+            <dd><code>{{.ID}}</code></dd>
+            <dt>Secret Status</dt>
+            <dd>
+              {{if .HasSecret}}
+                <span class="status-active">Secret configured</span>
+              {{else}}
+                <span class="status-inactive">No secret</span>
+              {{end}}
+            </dd>
+          </dl>
+        </div>
+        {{end}}
+      </div>
+    </main>
+
+    <script>
+      function copySecret(event) {
+        const secretInput = document.getElementById('client-secret');
+        secretInput.select();
+        secretInput.setSelectionRange(0, 99999); // For mobile devices
+        
+        navigator.clipboard.writeText(secretInput.value).then(function() {
+          const button = event.target;
+          const originalText = button.textContent;
+          button.textContent = 'Copied!';
+          button.classList.add('btn-success');
+          
+          setTimeout(function() {
+            button.textContent = originalText;
+            button.classList.remove('btn-success');
+          }, 2000);
+        }).catch(function(err) {
+          console.error('Failed to copy: ', err);
+          alert('Failed to copy to clipboard. Please copy manually.');
+        });
+      }
+      
+      function copyClientId(event) {
+        const clientIdInput = document.getElementById('client-id');
+        clientIdInput.select();
+        clientIdInput.setSelectionRange(0, 99999); // For mobile devices
+        
+        navigator.clipboard.writeText(clientIdInput.value).then(function() {
+          const button = event.target;
+          const originalText = button.textContent;
+          button.textContent = 'Copied!';
+          button.classList.add('btn-success');
+          
+          setTimeout(function() {
+            button.textContent = originalText;
+            button.classList.remove('btn-success');
+          }, 2000);
+        }).catch(function(err) {
+          console.error('Failed to copy: ', err);
+          alert('Failed to copy to clipboard. Please copy manually.');
+        });
+      }
+    </script>
+  </body>
+</html> 

+ 53 - 0
cmd/tsidp/ui-header.html

@@ -0,0 +1,53 @@
+<header>
+  <nav>
+    <svg
+      width="18"
+      height="18"
+      viewBox="0 0 23 23"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+      class="shrink-0"
+    >
+      <circle
+        opacity="0.2"
+        cx="3.4"
+        cy="3.25"
+        r="2.7"
+        fill="currentColor"
+      ></circle>
+      <circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
+      <circle
+        opacity="0.2"
+        cx="3.4"
+        cy="19.5"
+        r="2.7"
+        fill="currentColor"
+      ></circle>
+      <circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
+      <circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
+      <circle
+        opacity="0.2"
+        cx="11.5"
+        cy="3.25"
+        r="2.7"
+        fill="currentColor"
+      ></circle>
+      <circle
+        opacity="0.2"
+        cx="19.5"
+        cy="3.25"
+        r="2.7"
+        fill="currentColor"
+      ></circle>
+      <circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
+      <circle
+        opacity="0.2"
+        cx="19.5"
+        cy="19.5"
+        r="2.7"
+        fill="currentColor"
+      ></circle>
+    </svg>
+    <a href="/"><h1>Tailscale OIDC Identity Provider</h1></a>
+  </nav>
+</header> 

+ 73 - 0
cmd/tsidp/ui-list.html

@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Tailscale OIDC Identity Provider</title>
+    <link rel="stylesheet" type="text/css" href="/style.css" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  </head>
+
+  <body>
+    {{template "header"}}
+
+    <main>
+      <div class="header-actions">
+        <div>
+          <h2>OIDC Clients</h2>
+          {{if .}}
+            <p class="client-count">{{len .}} client{{if ne (len .) 1}}s{{end}} configured</p>
+          {{end}}
+        </div>
+        <a href="/new" class="btn btn-primary">Add New Client</a>
+      </div>
+
+      {{if .}}
+      <table>
+        <thead>
+          <tr>
+            <td>Name</td>
+            <td>Client ID</td>
+            <td>Redirect URI</td>
+            <td>Status</td>
+            <td>Actions</td>
+          </tr>
+        </thead>
+        <tbody>
+          {{range .}}
+          <tr>
+            <td>
+              {{if .Name}}
+                <strong>{{.Name}}</strong>
+              {{else}}
+                <span class="text-muted">Unnamed Client</span>
+              {{end}}
+            </td>
+            <td>
+              <code class="client-id">{{.ID}}</code>
+            </td>
+            <td>
+              <span class="redirect-uri">{{.RedirectURI}}</span>
+            </td>
+            <td>
+              {{if .HasSecret}}
+                <span class="status-active">Active</span>
+              {{else}}
+                <span class="status-inactive">No Secret</span>
+              {{end}}
+            </td>
+            <td>
+              <a href="/edit/{{.ID}}" class="btn btn-secondary btn-small">Edit</a>
+            </td>
+          </tr>
+          {{end}}
+        </tbody>
+      </table>
+      {{else}}
+      <div class="empty-state">
+        <h3>No OIDC clients configured</h3>
+        <p>Create your first OIDC client to get started with authentication.</p>
+        <a href="/new" class="btn btn-primary">Add New Client</a>
+      </div>
+      {{end}}
+    </main>
+  </body>
+</html> 

+ 446 - 0
cmd/tsidp/ui-style.css

@@ -0,0 +1,446 @@
+:root {
+  --tw-text-opacity: 1;
+  --color-gray-100: 247 245 244;
+  --color-gray-200: 238 235 234;
+  --color-gray-500: 112 110 109;
+  --color-gray-700: 46 45 45;
+  --color-gray-800: 35 34 34;
+  --color-gray-900: 31 30 30;
+  --color-bg-app: rgb(var(--color-gray-900) / 1);
+  --color-border-base: rgb(var(--color-gray-200) / 1);
+  --color-primary: 59 130 246;
+  --color-primary-hover: 37 99 235;
+  --color-secondary: 107 114 128;
+  --color-secondary-hover: 75 85 99;
+  --color-success: 34 197 94;
+  --color-warning: 245 158 11;
+  --color-danger: 239 68 68;
+  --color-danger-hover: 220 38 38;
+}
+
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+}
+
+body {
+  font-family: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial,
+    sans-serif;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  font-size: 16px;
+  line-height: 1.4;
+  margin: 0;
+  background-color: var(--color-bg-app);
+  color: rgb(var(--color-gray-200));
+}
+
+a {
+  text-decoration: none;
+  color: inherit;
+}
+
+header {
+  margin-top: 40px;
+}
+header nav {
+  margin: 0 auto;
+  max-width: 1120px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+header nav h1 {
+  display: inline;
+  font-weight: 600;
+  font-size: 1.125rem;
+  line-height: 1.75rem;
+  margin-left: 0.75rem;
+}
+
+main {
+  margin: 40px auto 60px auto;
+  max-width: 1120px;
+  padding: 0 20px;
+}
+
+/* Header actions */
+.header-actions {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 2rem;
+}
+
+.header-actions h2 {
+  font-size: 1.5rem;
+  font-weight: 600;
+  margin: 0 0 0.25rem 0;
+}
+
+.client-count {
+  font-size: 0.875rem;
+  color: rgb(var(--color-gray-500));
+  margin: 0;
+}
+
+/* Buttons */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  padding: 8px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  text-decoration: none;
+  border: none;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.btn-small {
+  padding: 4px 8px;
+  font-size: 12px;
+}
+
+.btn-primary {
+  background-color: rgb(var(--color-primary));
+  color: white;
+}
+
+.btn-primary:hover {
+  background-color: rgb(var(--color-primary-hover));
+}
+
+.btn-secondary {
+  background-color: rgb(var(--color-secondary));
+  color: white;
+}
+
+.btn-secondary:hover {
+  background-color: rgb(var(--color-secondary-hover));
+}
+
+.btn-success {
+  background-color: rgb(var(--color-success));
+  color: white;
+}
+
+.btn-warning {
+  background-color: rgb(var(--color-warning));
+  color: white;
+}
+
+.btn-danger {
+  background-color: rgb(var(--color-danger));
+  color: white;
+}
+
+.btn-danger:hover {
+  background-color: rgb(var(--color-danger-hover));
+}
+
+/* Tables */
+table {
+  width: 100%;
+  border-spacing: 0;
+  border: 1px solid rgb(var(--color-gray-700));
+  border-bottom-width: 0;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+td {
+  border: 0 solid rgb(var(--color-gray-700));
+  border-bottom-width: 1px;
+  padding: 12px 16px;
+}
+
+thead td {
+  text-transform: uppercase;
+  color: rgb(var(--color-gray-500) / var(--tw-text-opacity));
+  font-size: 12px;
+  letter-spacing: 0.08em;
+  font-weight: 600;
+  background-color: rgb(var(--color-gray-800));
+}
+
+tbody tr:hover {
+  background-color: rgb(var(--color-gray-800));
+}
+
+/* Client display elements */
+.client-id {
+  font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
+    Menlo, Consolas, monospace;
+  font-size: 12px;
+  background-color: rgb(var(--color-gray-800));
+  padding: 2px 6px;
+  border-radius: 4px;
+  color: rgb(var(--color-gray-200));
+}
+
+.redirect-uri {
+  font-size: 14px;
+  color: rgb(var(--color-gray-200));
+  word-break: break-all;
+}
+
+.status-active {
+  color: rgb(var(--color-success));
+  font-weight: 500;
+}
+
+.status-inactive {
+  color: rgb(var(--color-gray-500));
+  font-weight: 500;
+}
+
+.text-muted {
+  color: rgb(var(--color-gray-500));
+}
+
+/* Empty state */
+.empty-state {
+  text-align: center;
+  padding: 60px 20px;
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 8px;
+  background-color: rgb(var(--color-gray-800) / 0.5);
+}
+
+.empty-state h3 {
+  font-size: 1.25rem;
+  font-weight: 600;
+  margin-bottom: 0.5rem;
+  color: rgb(var(--color-gray-200));
+}
+
+.empty-state p {
+  color: rgb(var(--color-gray-500));
+  margin-bottom: 1.5rem;
+}
+
+/* Forms */
+.form-container {
+  max-width: 600px;
+  margin: 0 auto;
+}
+
+.form-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 2rem;
+}
+
+.form-header h2 {
+  font-size: 1.5rem;
+  font-weight: 600;
+  margin: 0;
+}
+
+.client-form {
+  background-color: rgb(var(--color-gray-800) / 0.5);
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 8px;
+  padding: 24px;
+  margin-bottom: 2rem;
+}
+
+.form-group {
+  margin-bottom: 1.5rem;
+}
+
+.form-group:last-child {
+  margin-bottom: 0;
+}
+
+.form-group label {
+  display: block;
+  font-weight: 500;
+  margin-bottom: 0.5rem;
+  color: rgb(var(--color-gray-200));
+}
+
+.required {
+  color: rgb(var(--color-danger));
+}
+
+.form-input {
+  width: 100%;
+  padding: 10px 12px;
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 6px;
+  background-color: rgb(var(--color-gray-900));
+  color: rgb(var(--color-gray-200));
+  font-size: 14px;
+}
+
+.form-input:focus {
+  outline: none;
+  border-color: rgb(var(--color-primary));
+  box-shadow: 0 0 0 3px rgb(var(--color-primary) / 0.1);
+}
+
+.form-input-readonly {
+  background-color: rgb(var(--color-gray-800));
+  color: rgb(var(--color-gray-500));
+}
+
+.form-help {
+  font-size: 12px;
+  color: rgb(var(--color-gray-500));
+  margin-top: 0.25rem;
+}
+
+.form-actions {
+  display: flex;
+  gap: 1rem;
+  margin-top: 2rem;
+  padding-top: 1.5rem;
+  border-top: 1px solid rgb(var(--color-gray-700));
+}
+
+/* Alerts */
+.alert {
+  padding: 12px 16px;
+  border-radius: 6px;
+  margin-bottom: 1.5rem;
+  font-size: 14px;
+}
+
+.alert-success {
+  background-color: rgb(var(--color-success) / 0.1);
+  border: 1px solid rgb(var(--color-success) / 0.3);
+  color: rgb(var(--color-success));
+}
+
+.alert-error {
+  background-color: rgb(var(--color-danger) / 0.1);
+  border: 1px solid rgb(var(--color-danger) / 0.3);
+  color: rgb(var(--color-danger));
+}
+
+/* Secret display */
+.secret-display {
+  background-color: rgb(var(--color-gray-800) / 0.5);
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 8px;
+  padding: 20px;
+  margin-bottom: 2rem;
+}
+
+.secret-display h3 {
+  font-size: 1.125rem;
+  font-weight: 600;
+  margin-bottom: 0.5rem;
+  color: rgb(var(--color-gray-200));
+}
+
+.warning {
+  color: rgb(var(--color-warning));
+  font-weight: 500;
+  margin-bottom: 1rem;
+}
+
+.secret-field {
+  display: flex;
+  gap: 0.5rem;
+}
+
+.secret-input {
+  flex: 1;
+  padding: 10px 12px;
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 6px;
+  background-color: rgb(var(--color-gray-900));
+  color: rgb(var(--color-gray-200));
+  font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
+    Menlo, Consolas, monospace;
+  font-size: 12px;
+}
+
+/* Client info */
+.client-info {
+  background-color: rgb(var(--color-gray-800) / 0.5);
+  border: 1px solid rgb(var(--color-gray-700));
+  border-radius: 8px;
+  padding: 20px;
+}
+
+.client-info h3 {
+  font-size: 1.125rem;
+  font-weight: 600;
+  margin-bottom: 1rem;
+  color: rgb(var(--color-gray-200));
+}
+
+.client-info dl {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: 0.5rem 1rem;
+  border: none;
+  border-radius: 0;
+  padding: 0;
+}
+
+.client-info dt {
+  font-weight: 600;
+  color: rgb(var(--color-gray-400));
+  border: none;
+  padding: 0;
+}
+
+.client-info dd {
+  color: rgb(var(--color-gray-200));
+  border: none;
+  padding: 0;
+}
+
+.client-info code {
+  font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono",
+    Menlo, Consolas, monospace;
+  font-size: 12px;
+  background-color: rgb(var(--color-gray-800));
+  padding: 2px 6px;
+  border-radius: 4px;
+  color: rgb(var(--color-gray-200));
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+  .header-actions {
+    flex-direction: column;
+    align-items: stretch;
+    gap: 1rem;
+  }
+
+  .form-header {
+    flex-direction: column;
+    align-items: stretch;
+    gap: 1rem;
+  }
+
+  .form-actions {
+    flex-direction: column;
+  }
+
+  .secret-field {
+    flex-direction: column;
+  }
+
+  table {
+    font-size: 14px;
+  }
+  
+  td {
+    padding: 8px 12px;
+  }
+
+  .client-id {
+    font-size: 10px;
+  }
+} 

+ 325 - 0
cmd/tsidp/ui.go

@@ -0,0 +1,325 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"bytes"
+	_ "embed"
+	"html/template"
+	"log"
+	"net/http"
+	"net/url"
+	"sort"
+	"strings"
+	"time"
+
+	"tailscale.com/util/rands"
+)
+
+//go:embed ui-header.html
+var headerHTML string
+
+//go:embed ui-list.html
+var listHTML string
+
+//go:embed ui-edit.html
+var editHTML string
+
+//go:embed ui-style.css
+var styleCSS string
+
+var headerTmpl = template.Must(template.New("header").Parse(headerHTML))
+var listTmpl = template.Must(headerTmpl.New("list").Parse(listHTML))
+var editTmpl = template.Must(headerTmpl.New("edit").Parse(editHTML))
+
+var processStart = time.Now()
+
+func (s *idpServer) handleUI(w http.ResponseWriter, r *http.Request) {
+	if isFunnelRequest(r) {
+		http.Error(w, "tsidp: UI not available over Funnel", http.StatusNotFound)
+		return
+	}
+
+	switch r.URL.Path {
+	case "/":
+		s.handleClientsList(w, r)
+		return
+	case "/new":
+		s.handleNewClient(w, r)
+		return
+	case "/style.css":
+		http.ServeContent(w, r, "ui-style.css", processStart, strings.NewReader(styleCSS))
+		return
+	}
+
+	if strings.HasPrefix(r.URL.Path, "/edit/") {
+		s.handleEditClient(w, r)
+		return
+	}
+
+	http.Error(w, "tsidp: not found", http.StatusNotFound)
+}
+
+func (s *idpServer) handleClientsList(w http.ResponseWriter, r *http.Request) {
+	s.mu.Lock()
+	clients := make([]clientDisplayData, 0, len(s.funnelClients))
+	for _, c := range s.funnelClients {
+		clients = append(clients, clientDisplayData{
+			ID:          c.ID,
+			Name:        c.Name,
+			RedirectURI: c.RedirectURI,
+			HasSecret:   c.Secret != "",
+		})
+	}
+	s.mu.Unlock()
+
+	sort.Slice(clients, func(i, j int) bool {
+		if clients[i].Name != clients[j].Name {
+			return clients[i].Name < clients[j].Name
+		}
+		return clients[i].ID < clients[j].ID
+	})
+
+	var buf bytes.Buffer
+	if err := listTmpl.Execute(&buf, clients); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	buf.WriteTo(w)
+}
+
+func (s *idpServer) handleNewClient(w http.ResponseWriter, r *http.Request) {
+	if r.Method == "GET" {
+		if err := s.renderClientForm(w, clientDisplayData{IsNew: true}); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+		return
+	}
+
+	if r.Method == "POST" {
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, "Failed to parse form", http.StatusBadRequest)
+			return
+		}
+
+		name := strings.TrimSpace(r.FormValue("name"))
+		redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
+
+		baseData := clientDisplayData{
+			IsNew:       true,
+			Name:        name,
+			RedirectURI: redirectURI,
+		}
+
+		if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
+			s.renderFormError(w, baseData, errMsg)
+			return
+		}
+
+		clientID := rands.HexString(32)
+		clientSecret := rands.HexString(64)
+		newClient := funnelClient{
+			ID:          clientID,
+			Secret:      clientSecret,
+			Name:        name,
+			RedirectURI: redirectURI,
+		}
+
+		s.mu.Lock()
+		if s.funnelClients == nil {
+			s.funnelClients = make(map[string]*funnelClient)
+		}
+		s.funnelClients[clientID] = &newClient
+		err := s.storeFunnelClientsLocked()
+		s.mu.Unlock()
+
+		if err != nil {
+			log.Printf("could not write funnel clients db: %v", err)
+			s.renderFormError(w, baseData, "Failed to save client")
+			return
+		}
+
+		successData := clientDisplayData{
+			ID:          clientID,
+			Name:        name,
+			RedirectURI: redirectURI,
+			Secret:      clientSecret,
+			IsNew:       true,
+		}
+		s.renderFormSuccess(w, successData, "Client created successfully! Save the client secret - it won't be shown again.")
+		return
+	}
+
+	http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+}
+
+func (s *idpServer) handleEditClient(w http.ResponseWriter, r *http.Request) {
+	clientID := strings.TrimPrefix(r.URL.Path, "/edit/")
+	if clientID == "" {
+		http.Error(w, "Client ID required", http.StatusBadRequest)
+		return
+	}
+
+	s.mu.Lock()
+	client, exists := s.funnelClients[clientID]
+	s.mu.Unlock()
+
+	if !exists {
+		http.Error(w, "Client not found", http.StatusNotFound)
+		return
+	}
+
+	if r.Method == "GET" {
+		data := createEditBaseData(client, client.Name, client.RedirectURI)
+		if err := s.renderClientForm(w, data); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+		return
+	}
+
+	if r.Method == "POST" {
+		action := r.FormValue("action")
+
+		if action == "delete" {
+			s.mu.Lock()
+			delete(s.funnelClients, clientID)
+			err := s.storeFunnelClientsLocked()
+			s.mu.Unlock()
+
+			if err != nil {
+				log.Printf("could not write funnel clients db: %v", err)
+				s.mu.Lock()
+				s.funnelClients[clientID] = client
+				s.mu.Unlock()
+
+				baseData := createEditBaseData(client, client.Name, client.RedirectURI)
+				s.renderFormError(w, baseData, "Failed to delete client. Please try again.")
+				return
+			}
+
+			http.Redirect(w, r, "/", http.StatusSeeOther)
+			return
+		}
+
+		if action == "regenerate_secret" {
+			newSecret := rands.HexString(64)
+			s.mu.Lock()
+			s.funnelClients[clientID].Secret = newSecret
+			err := s.storeFunnelClientsLocked()
+			s.mu.Unlock()
+
+			baseData := createEditBaseData(client, client.Name, client.RedirectURI)
+			baseData.HasSecret = true
+
+			if err != nil {
+				log.Printf("could not write funnel clients db: %v", err)
+				s.renderFormError(w, baseData, "Failed to regenerate secret")
+				return
+			}
+
+			baseData.Secret = newSecret
+			s.renderFormSuccess(w, baseData, "New client secret generated! Save it - it won't be shown again.")
+			return
+		}
+
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, "Failed to parse form", http.StatusBadRequest)
+			return
+		}
+
+		name := strings.TrimSpace(r.FormValue("name"))
+		redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
+		baseData := createEditBaseData(client, name, redirectURI)
+
+		if errMsg := validateRedirectURI(redirectURI); errMsg != "" {
+			s.renderFormError(w, baseData, errMsg)
+			return
+		}
+
+		s.mu.Lock()
+		s.funnelClients[clientID].Name = name
+		s.funnelClients[clientID].RedirectURI = redirectURI
+		err := s.storeFunnelClientsLocked()
+		s.mu.Unlock()
+
+		if err != nil {
+			log.Printf("could not write funnel clients db: %v", err)
+			s.renderFormError(w, baseData, "Failed to update client")
+			return
+		}
+
+		s.renderFormSuccess(w, baseData, "Client updated successfully!")
+		return
+	}
+
+	http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+}
+
+type clientDisplayData struct {
+	ID          string
+	Name        string
+	RedirectURI string
+	Secret      string
+	HasSecret   bool
+	IsNew       bool
+	IsEdit      bool
+	Success     string
+	Error       string
+}
+
+func (s *idpServer) renderClientForm(w http.ResponseWriter, data clientDisplayData) error {
+	var buf bytes.Buffer
+	if err := editTmpl.Execute(&buf, data); err != nil {
+		return err
+	}
+	if _, err := buf.WriteTo(w); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *idpServer) renderFormError(w http.ResponseWriter, data clientDisplayData, errorMsg string) {
+	data.Error = errorMsg
+	if err := s.renderClientForm(w, data); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+func (s *idpServer) renderFormSuccess(w http.ResponseWriter, data clientDisplayData, successMsg string) {
+	data.Success = successMsg
+	if err := s.renderClientForm(w, data); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
+func createEditBaseData(client *funnelClient, name, redirectURI string) clientDisplayData {
+	return clientDisplayData{
+		ID:          client.ID,
+		Name:        name,
+		RedirectURI: redirectURI,
+		HasSecret:   client.Secret != "",
+		IsEdit:      true,
+	}
+}
+
+func validateRedirectURI(redirectURI string) string {
+	if redirectURI == "" {
+		return "Redirect URI is required"
+	}
+
+	u, err := url.Parse(redirectURI)
+	if err != nil {
+		return "Invalid URL format"
+	}
+
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return "Redirect URI must be a valid HTTP or HTTPS URL"
+	}
+
+	if u.Host == "" {
+		return "Redirect URI must include a valid host"
+	}
+
+	return ""
+}