added on local at 2026-07-01 21:47:17
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- account / identity page. | |
| 4 | # | |
| 5 | # GET /profile.cgi | |
| 6 | # | |
| 7 | # POST act=update_profile display_name, email, timezone, default_currency | |
| 8 | # POST act=change_password old_password, new_password, confirm_password | |
| 9 | # POST act=resend_verification (re-mint + email a verification link) | |
| 10 | # POST act=update_notifications notify_floor_hit, notify_buybox_lost, notify_weekly_summary | |
| 11 | # POST act=update_slack slack_webhook_url | |
| 12 | # POST act=create_api_key label (Studio tier only) | |
| 13 | # POST act=revoke_api_key key_id | |
| 14 | # | |
| 15 | # 2FA enable/disable is in /two_factor_setup.cgi (separate flow). | |
| 16 | # | |
| 17 | # UI-prefs (notifications/theme/etc) live at /preferences.cgi -- this | |
| 18 | # file is the account-identity surface only. Renamed from the legacy | |
| 19 | # tabbed /settings.cgi 2026-06-20 to match the PTMatrix URL convention. | |
| 20 | #====================================================================== | |
| 21 | use strict; | |
| 22 | use warnings; | |
| 23 | ||
| 24 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 25 | use CGI; | |
| 26 | use Digest::SHA qw(sha256_hex); | |
| 27 | use MODS::DBConnect; | |
| 28 | use MODS::Login; | |
| 29 | use MODS::RePricer::Config; | |
| 30 | use MODS::RePricer::Wrapper; | |
| 31 | ||
| 32 | $|=1; | |
| 33 | ||
| 34 | my $q = CGI->new; | |
| 35 | my $form = $q->Vars; | |
| 36 | my $auth = MODS::Login->new; | |
| 37 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 38 | my $db = MODS::DBConnect->new; | |
| 39 | my $cfg = MODS::RePricer::Config->new; | |
| 40 | my $DB = $cfg->settings('database_name'); | |
| 41 | ||
| 42 | my $userinfo = $auth->login_verify(); | |
| 43 | unless ($userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 44 | my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g; | |
| 45 | ||
| 46 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s } | |
| 47 | sub _q { my $s = shift // ''; $s =~ s/'/''/g; $s } | |
| 48 | sub _e { my $s = shift // ''; $s =~ s/([^A-Za-z0-9\-._~])/sprintf('%%%02X', ord($1))/ge; $s } | |
| 49 | ||
| 50 | my $dbh = $db->db_connect(); | |
| 51 | ||
| 52 | # ----- POST handlers ------------------------------------------------- | |
| 53 | my $flash = ''; my $kind = ''; | |
| 54 | if (($ENV{REQUEST_METHOD} || '') eq 'POST') { | |
| 55 | my $act = $form->{act} || ''; | |
| 56 | ||
| 57 | if ($act eq 'update_profile') { | |
| 58 | my $name = substr(_q($form->{display_name} // ''), 0, 80); | |
| 59 | my $email = substr(_q($form->{email} // ''), 0, 191); | |
| 60 | my $tz = substr(_q($form->{timezone} // ''), 0, 64); | |
| 61 | my $cur = ($form->{default_currency} || '') =~ /^([A-Z]{3})$/ ? $1 : ''; | |
| 62 | unless ($email =~ /\@/) { | |
| 63 | $flash = 'Email looks malformed.'; $kind = 'error'; | |
| 64 | } else { | |
| 65 | $db->db_readwrite($dbh, qq~ | |
| 66 | UPDATE `${DB}`.users SET | |
| 67 | display_name='$name', | |
| 68 | email='$email', | |
| 69 | timezone='$tz' | |
| 70 | ~ . ($cur ? ", default_currency='$cur'" : "") . qq~ | |
| 71 | WHERE id='$uid' | |
| 72 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 73 | $flash = 'Profile updated.'; $kind = 'success'; | |
| 74 | } | |
| 75 | } | |
| 76 | elsif ($act eq 'change_password') { | |
| 77 | my $old_pw = $form->{old_password} // ''; | |
| 78 | my $new_pw = $form->{new_password} // ''; | |
| 79 | my $cf_pw = $form->{confirm_password} // ''; | |
| 80 | if (length($new_pw) < 8) { | |
| 81 | $flash = 'New password must be at least 8 characters.'; $kind = 'error'; | |
| 82 | } elsif ($new_pw ne $cf_pw) { | |
| 83 | $flash = 'New password and confirmation do not match.'; $kind = 'error'; | |
| 84 | } else { | |
| 85 | # Verify current password. Hashing: the existing scheme uses | |
| 86 | # sha256_hex of password (matches what Login likely does). | |
| 87 | # Adjust here if your Login module uses a different scheme. | |
| 88 | my $row = $db->db_readwrite($dbh, qq~ | |
| 89 | SELECT password_hash FROM `${DB}`.users WHERE id='$uid' | |
| 90 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 91 | my $stored = $row->{password_hash} // ''; | |
| 92 | my $check = sha256_hex($old_pw); | |
| 93 | if ($stored ne $check) { | |
| 94 | $flash = 'Current password is wrong.'; $kind = 'error'; | |
| 95 | } else { | |
| 96 | my $new_hash = sha256_hex($new_pw); | |
| 97 | $db->db_readwrite($dbh, qq~ | |
| 98 | UPDATE `${DB}`.users SET password_hash='$new_hash' WHERE id='$uid' | |
| 99 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 100 | $flash = 'Password changed.'; $kind = 'success'; | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | elsif ($act eq 'update_slack') { | |
| 105 | my $url = $form->{slack_webhook_url} // ''; | |
| 106 | $url =~ s/^\s+|\s+$//g; | |
| 107 | # Allow empty (clears) or a Slack-shaped URL only. | |
| 108 | if ($url ne '' && $url !~ m{^https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+$}) { | |
| 109 | $flash = 'Slack webhook URL must look like https://hooks.slack.com/services/T.../B.../...'; $kind = 'error'; | |
| 110 | } else { | |
| 111 | my $qurl = _q($url); | |
| 112 | $db->db_readwrite($dbh, qq~ | |
| 113 | UPDATE `${DB}`.users SET slack_webhook_url='$qurl' WHERE id='$uid' | |
| 114 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 115 | $flash = $url ? 'Slack webhook saved.' : 'Slack webhook cleared.'; $kind = 'success'; | |
| 116 | } | |
| 117 | } | |
| 118 | elsif ($act eq 'create_api_key') { | |
| 119 | # Studio-tier gate -- enforce server-side, even though UI hides it. | |
| 120 | my $trow = $db->db_readwrite($dbh, qq~ | |
| 121 | SELECT plan_tier FROM `${DB}`.users WHERE id='$uid' | |
| 122 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 123 | my $cur_tier = $trow->{plan_tier} || 'free'; | |
| 124 | if ($cur_tier ne 'studio') { | |
| 125 | $flash = 'API access is a Studio-tier feature. Upgrade in /billing.cgi.'; $kind = 'error'; | |
| 126 | } else { | |
| 127 | my $label = substr(_q($form->{label} // 'unnamed'), 0, 60); | |
| 128 | # Token format: rpc_live_<48 hex>. We store the sha256 hash; | |
| 129 | # the plain token is shown ONCE in the flash. | |
| 130 | my @chars = ('a'..'f', 0..9); | |
| 131 | my $plain = 'rpc_live_' . join('', map { $chars[int(rand(@chars))] } 1..48); | |
| 132 | my $h = sha256_hex($plain); | |
| 133 | # key_prefix gives the user something to recognize their key by | |
| 134 | # in lists; we never display the full token after creation. | |
| 135 | my $prefix = substr($plain, 0, 8); | |
| 136 | $db->db_readwrite($dbh, qq~ | |
| 137 | INSERT INTO `${DB}`.api_keys (user_id, label, key_prefix, key_hash, created_at) | |
| 138 | VALUES ('$uid', '$label', '$prefix', '$h', NOW()) | |
| 139 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 140 | $flash = "API key created. Copy it now -- it will not be shown again:|$plain"; | |
| 141 | $kind = 'success'; | |
| 142 | } | |
| 143 | } | |
| 144 | elsif ($act eq 'revoke_api_key') { | |
| 145 | my $kid = $form->{key_id} || ''; $kid =~ s/[^0-9]//g; | |
| 146 | if ($kid) { | |
| 147 | $db->db_readwrite($dbh, qq~ | |
| 148 | UPDATE `${DB}`.api_keys SET revoked_at=NOW() | |
| 149 | WHERE id='$kid' AND user_id='$uid' AND revoked_at IS NULL | |
| 150 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 151 | $flash = 'API key revoked.'; $kind = 'success'; | |
| 152 | } | |
| 153 | } | |
| 154 | elsif ($act eq 'update_notifications') { | |
| 155 | my $a = $form->{notify_floor_hit} ? 1 : 0; | |
| 156 | my $b = $form->{notify_buybox_lost} ? 1 : 0; | |
| 157 | my $c = $form->{notify_weekly_summary} ? 1 : 0; | |
| 158 | $db->db_readwrite($dbh, qq~ | |
| 159 | UPDATE `${DB}`.users SET | |
| 160 | notify_floor_hit='$a', | |
| 161 | notify_buybox_lost='$b', | |
| 162 | notify_weekly_summary='$c' | |
| 163 | WHERE id='$uid' | |
| 164 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 165 | $flash = 'Notification preferences saved.'; $kind = 'success'; | |
| 166 | } | |
| 167 | elsif ($act eq 'resend_verification') { | |
| 168 | # Re-mint a verification token and email the link. We require | |
| 169 | # the account to NOT already be verified -- there's no reason | |
| 170 | # to ship another email after the user already confirmed. | |
| 171 | # (Migrated from the legacy settings.cgi 2026-06-20.) | |
| 172 | require MODS::RePricer::EmailVerify; | |
| 173 | my $u_check = $db->db_readwrite($dbh, qq~ | |
| 174 | SELECT id, email, display_name, email_verified_at | |
| 175 | FROM `${DB}`.users WHERE id='$uid' LIMIT 1 | |
| 176 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 177 | if ($u_check && $u_check->{id} && !$u_check->{email_verified_at}) { | |
| 178 | my $r = MODS::RePricer::EmailVerify->send_for_user( | |
| 179 | $db, $dbh, $DB, | |
| 180 | { id => $u_check->{id}, email => $u_check->{email}, display_name => $u_check->{display_name} }, | |
| 181 | ); | |
| 182 | if ($r && $r->{ok}) { | |
| 183 | $flash = 'Verification email sent. Check your inbox (and spam folder).'; | |
| 184 | $kind = 'success'; | |
| 185 | } else { | |
| 186 | $flash = 'Could not send verification email: ' . ($r->{error} || 'unknown error'); | |
| 187 | $kind = 'error'; | |
| 188 | } | |
| 189 | } else { | |
| 190 | $flash = 'Your email is already verified.'; | |
| 191 | $kind = 'success'; | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | # ----- Load current row ---------------------------------------------- | |
| 197 | my $u = $db->db_readwrite($dbh, qq~ | |
| 198 | SELECT email, display_name, timezone, default_currency, | |
| 199 | two_factor_enabled, | |
| 200 | email_verified_at, | |
| 201 | DATE_FORMAT(email_verified_at, '%b %e, %Y') AS verified_on, | |
| 202 | notify_floor_hit, notify_buybox_lost, notify_weekly_summary, | |
| 203 | slack_webhook_url, plan_tier, | |
| 204 | created_at, last_login_at | |
| 205 | FROM `${DB}`.users WHERE id='$uid' | |
| 206 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 207 | ||
| 208 | my $api_keys = []; | |
| 209 | my $tier = $u->{plan_tier} || 'free'; | |
| 210 | if ($tier eq 'studio') { | |
| 211 | $api_keys = $db->db_readwrite_multiple($dbh, qq~ | |
| 212 | SELECT id, label, created_at, last_used_at, revoked_at | |
| 213 | FROM `${DB}`.api_keys | |
| 214 | WHERE user_id='$uid' | |
| 215 | ORDER BY id DESC | |
| 216 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 217 | } | |
| 218 | ||
| 219 | $db->db_disconnect($dbh); | |
| 220 | ||
| 221 | my $name = _h($u->{display_name} // ''); | |
| 222 | my $email = _h($u->{email} // ''); | |
| 223 | my $tz = _h($u->{timezone} || 'UTC'); | |
| 224 | my $cur = _h($u->{default_currency} || 'USD'); | |
| 225 | my $tfa = $u->{two_factor_enabled} ? 1 : 0; | |
| 226 | my $created = _h($u->{created_at} // ''); | |
| 227 | my $last_login = _h($u->{last_login_at} // 'never'); | |
| 228 | my $email_verified = $u->{email_verified_at} ? 1 : 0; | |
| 229 | my $verified_label = $email_verified | |
| 230 | ? ('Verified ' . _h($u->{verified_on} || '')) | |
| 231 | : 'Not verified yet'; | |
| 232 | my $nf = $u->{notify_floor_hit} ? 'checked' : ''; | |
| 233 | my $nbb = $u->{notify_buybox_lost} ? 'checked' : ''; | |
| 234 | my $nws = $u->{notify_weekly_summary} ? 'checked' : ''; | |
| 235 | my $slack_url = _h($u->{slack_webhook_url} // ''); | |
| 236 | ||
| 237 | # ----- Render -------------------------------------------------------- | |
| 238 | my $body = '<div style="max-width:780px;margin:0 auto;padding:24px 28px">'; | |
| 239 | ||
| 240 | # Header | |
| 241 | $body .= '<div style="margin-bottom:24px">'; | |
| 242 | $body .= ' <div style="font-size:11px;letter-spacing:2px;color:#14b8a6;text-transform:uppercase">Account</div>'; | |
| 243 | $body .= ' <h1 style="font-size:26px;color:#fff;margin:6px 0 4px;font-weight:700">Profile & preferences</h1>'; | |
| 244 | $body .= ' <p style="color:#7e92b6;font-size:14px;margin:0">Account created ' . $created . ' · last login ' . $last_login . '</p>'; | |
| 245 | $body .= '</div>'; | |
| 246 | ||
| 247 | if (length $flash) { | |
| 248 | my $color = $kind eq 'success' ? '#10b981' : ($kind eq 'error' ? '#fca5a5' : '#10b981'); | |
| 249 | # Optional reveal-once payload separated by '|' (used by create_api_key) | |
| 250 | my ($msg_raw, $reveal) = split /\|/, $flash, 2; | |
| 251 | $body .= qq~<div style="background:${color}1a;border-left:3px solid $color;color:$color;padding:12px 16px;border-radius:8px;margin-bottom:8px;font-size:14px">~ . _h($msg_raw) . qq~</div>~; | |
| 252 | if ($reveal) { | |
| 253 | $body .= '<div style="background:#0b1226;border:2px solid #fbbf24;color:#fbbf24;padding:14px 18px;border-radius:8px;margin-bottom:18px;font-family:ui-monospace,Menlo,monospace;font-size:14px;word-break:break-all">' . _h($reveal) . '</div>'; | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | # Profile section | |
| 258 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 259 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px">Profile</div>'; | |
| 260 | $body .= qq~ <form method="POST" style="display:flex;flex-direction:column;gap:12px"> | |
| 261 | <input type="hidden" name="act" value="update_profile"> | |
| 262 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 263 | <span style="color:#7e92b6;font-size:12px">Display name</span> | |
| 264 | <input type="text" name="display_name" value="$name" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 265 | </label> | |
| 266 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 267 | <span style="color:#7e92b6;font-size:12px">Email</span> | |
| 268 | <input type="email" name="email" value="$email" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 269 | </label> | |
| 270 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> | |
| 271 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 272 | <span style="color:#7e92b6;font-size:12px">Timezone (IANA, e.g. America/New_York)</span> | |
| 273 | <input type="text" name="timezone" value="$tz" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 274 | </label> | |
| 275 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 276 | <span style="color:#7e92b6;font-size:12px">Default currency</span> | |
| 277 | <input type="text" name="default_currency" value="$cur" maxlength="3" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px;text-transform:uppercase"> | |
| 278 | </label> | |
| 279 | </div> | |
| 280 | <div style="text-align:right"> | |
| 281 | <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer">Save profile</button> | |
| 282 | </div> | |
| 283 | </form>~; | |
| 284 | $body .= '</section>'; | |
| 285 | ||
| 286 | # Password section | |
| 287 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 288 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px">Change password</div>'; | |
| 289 | $body .= ' <form method="POST" style="display:flex;flex-direction:column;gap:12px"> | |
| 290 | <input type="hidden" name="act" value="change_password"> | |
| 291 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 292 | <span style="color:#7e92b6;font-size:12px">Current password</span> | |
| 293 | <input type="password" name="old_password" autocomplete="current-password" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 294 | </label> | |
| 295 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 296 | <span style="color:#7e92b6;font-size:12px">New password (min 8 characters)</span> | |
| 297 | <input type="password" name="new_password" autocomplete="new-password" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 298 | </label> | |
| 299 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 300 | <span style="color:#7e92b6;font-size:12px">Confirm new password</span> | |
| 301 | <input type="password" name="confirm_password" autocomplete="new-password" style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 302 | </label> | |
| 303 | <div style="text-align:right"> | |
| 304 | <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer">Change password</button> | |
| 305 | </div> | |
| 306 | </form>'; | |
| 307 | $body .= '</section>'; | |
| 308 | ||
| 309 | # Email verification section (merged in from legacy settings.cgi 2026-06-20) | |
| 310 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 311 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:8px">Email verification</div>'; | |
| 312 | if ($email_verified) { | |
| 313 | $body .= ' <div style="color:#10b981;font-size:14px;font-weight:600">✓ Email verified</div>'; | |
| 314 | $body .= ' <div style="color:#7e92b6;font-size:13px;margin-top:6px">' . $verified_label . '.</div>'; | |
| 315 | } else { | |
| 316 | $body .= ' <div style="color:#fbbf24;font-size:14px;font-weight:600">Email not verified yet</div>'; | |
| 317 | $body .= ' <div style="color:#7e92b6;font-size:13px;margin-top:6px">Verify your address so password resets and important account notices reach you reliably.</div>'; | |
| 318 | $body .= ' <form method="POST" style="margin-top:10px">'; | |
| 319 | $body .= ' <input type="hidden" name="act" value="resend_verification">'; | |
| 320 | $body .= ' <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 14px;border-radius:6px;font-weight:600;cursor:pointer;font-size:13px">Send verification email</button>'; | |
| 321 | $body .= ' </form>'; | |
| 322 | } | |
| 323 | $body .= '</section>'; | |
| 324 | ||
| 325 | # 2FA section -- link out to dedicated CGI | |
| 326 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 327 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:8px">Two-factor authentication</div>'; | |
| 328 | if ($tfa) { | |
| 329 | $body .= ' <div style="color:#10b981;font-size:14px;font-weight:600">✓ 2FA is ON</div>'; | |
| 330 | $body .= ' <div style="color:#7e92b6;font-size:13px;margin-top:6px">An authenticator code is required on every sign-in.</div>'; | |
| 331 | $body .= ' <div style="margin-top:10px"><a href="/two_factor_setup.cgi" style="color:#14b8a6;font-size:13px">Manage 2FA →</a></div>'; | |
| 332 | } else { | |
| 333 | $body .= ' <div style="color:#fbbf24;font-size:14px;font-weight:600">2FA is OFF</div>'; | |
| 334 | $body .= ' <div style="color:#7e92b6;font-size:13px;margin-top:6px">Add a second factor to protect your account from password compromise. Works with Google Authenticator, Authy, 1Password, and any TOTP-compatible app.</div>'; | |
| 335 | $body .= ' <div style="margin-top:10px"><a href="/two_factor_setup.cgi" style="background:#064e3b;color:#a7f3d0;padding:8px 14px;border-radius:6px;text-decoration:none;font-weight:600;font-size:13px">Enable 2FA</a></div>'; | |
| 336 | } | |
| 337 | $body .= '</section>'; | |
| 338 | ||
| 339 | # Notification prefs | |
| 340 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 341 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px">Email notifications</div>'; | |
| 342 | $body .= qq~ <form method="POST" style="display:flex;flex-direction:column;gap:10px"> | |
| 343 | <input type="hidden" name="act" value="update_notifications"> | |
| 344 | <label style="display:flex;align-items:center;gap:10px;cursor:pointer"> | |
| 345 | <input type="checkbox" name="notify_floor_hit" value="1" $nf> | |
| 346 | <div> | |
| 347 | <div style="color:#e6ecf6;font-size:14px">Floor-hit alerts</div> | |
| 348 | <div style="color:#7e92b6;font-size:12px">Email when a product's repricer wanted to go below its floor.</div> | |
| 349 | </div> | |
| 350 | </label> | |
| 351 | <label style="display:flex;align-items:center;gap:10px;cursor:pointer"> | |
| 352 | <input type="checkbox" name="notify_buybox_lost" value="1" $nbb> | |
| 353 | <div> | |
| 354 | <div style="color:#e6ecf6;font-size:14px">Buy Box lost</div> | |
| 355 | <div style="color:#7e92b6;font-size:12px">Email when a product you were holding the Buy Box on loses it.</div> | |
| 356 | </div> | |
| 357 | </label> | |
| 358 | <label style="display:flex;align-items:center;gap:10px;cursor:pointer"> | |
| 359 | <input type="checkbox" name="notify_weekly_summary" value="1" $nws> | |
| 360 | <div> | |
| 361 | <div style="color:#e6ecf6;font-size:14px">Weekly summary</div> | |
| 362 | <div style="color:#7e92b6;font-size:12px">Every Monday: Buy Box win rate, reprice count, top winners + losers.</div> | |
| 363 | </div> | |
| 364 | </label> | |
| 365 | <div style="text-align:right"> | |
| 366 | <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer">Save preferences</button> | |
| 367 | </div> | |
| 368 | </form>~; | |
| 369 | $body .= '</section>'; | |
| 370 | ||
| 371 | # Slack webhook section | |
| 372 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 373 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px">Slack alerts (optional)</div>'; | |
| 374 | $body .= qq~ <form method="POST" style="display:flex;flex-direction:column;gap:10px"> | |
| 375 | <input type="hidden" name="act" value="update_slack"> | |
| 376 | <label style="display:flex;flex-direction:column;gap:4px"> | |
| 377 | <span style="color:#7e92b6;font-size:12px">Incoming webhook URL</span> | |
| 378 | <input type="url" name="slack_webhook_url" value="$slack_url" placeholder="https://hooks.slack.com/services/T.../B.../..." style="padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px;font-family:ui-monospace,Menlo,monospace;font-size:12px"> | |
| 379 | </label> | |
| 380 | <div style="color:#7e92b6;font-size:12px">When enabled, the events checked above also post to Slack. Create an incoming webhook in your Slack workspace and paste the URL here. Leave blank to disable.</div> | |
| 381 | <div style="text-align:right"> | |
| 382 | <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer">Save webhook</button> | |
| 383 | </div> | |
| 384 | </form>~; | |
| 385 | $body .= '</section>'; | |
| 386 | ||
| 387 | # API keys section -- Studio tier only | |
| 388 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 389 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:6px">REST API access</div>'; | |
| 390 | if ($tier ne 'studio') { | |
| 391 | $body .= ' <div style="color:#fbbf24;font-size:13px;margin-bottom:6px">Studio plan feature</div>'; | |
| 392 | $body .= ' <div style="color:#7e92b6;font-size:13px">Get programmatic access to products, rules, and price changes via /api/v1. <a href="/billing.cgi" style="color:#14b8a6">Upgrade to Studio</a> to enable.</div>'; | |
| 393 | } else { | |
| 394 | $body .= ' <div style="color:#7e92b6;font-size:13px;margin-bottom:14px">Use these tokens as <code style="color:#14b8a6">Authorization: Bearer rpc_live_…</code> against <code style="color:#14b8a6">/api/v1/…</code>. Endpoints: <code>me</code>, <code>products</code>, <code>products/N</code>, <code>rules</code>, <code>price_changes</code>.</div>'; | |
| 395 | $body .= ' <form method="POST" style="display:flex;gap:8px;margin-bottom:14px">'; | |
| 396 | $body .= ' <input type="hidden" name="act" value="create_api_key">'; | |
| 397 | $body .= ' <input type="text" name="label" placeholder="Label (e.g. zapier, my-laptop)" maxlength="60" style="flex:1;padding:8px 10px;background:#0a0f1f;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px">'; | |
| 398 | $body .= ' <button type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:8px 16px;border-radius:6px;font-weight:600;cursor:pointer">Create key</button>'; | |
| 399 | $body .= ' </form>'; | |
| 400 | if (@$api_keys) { | |
| 401 | $body .= ' <table style="width:100%;font-size:13px;border-collapse:collapse"><thead><tr style="color:#7e92b6;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:1px"><th style="padding:6px 4px">Label</th><th style="padding:6px 4px">Created</th><th style="padding:6px 4px">Last used</th><th style="padding:6px 4px">Status</th><th></th></tr></thead><tbody>'; | |
| 402 | foreach my $k (@$api_keys) { | |
| 403 | my $st = $k->{revoked_at} ? '<span style="color:#fca5a5">revoked</span>' : '<span style="color:#10b981">active</span>'; | |
| 404 | my $lu = $k->{last_used_at} || '<span style="color:#7e92b6">never</span>'; | |
| 405 | $body .= '<tr style="border-top:1px solid #1f2a4a">'; | |
| 406 | $body .= '<td style="padding:6px 4px;color:#e6ecf6">' . _h($k->{label}) . '</td>'; | |
| 407 | $body .= '<td style="padding:6px 4px;color:#7e92b6">' . _h($k->{created_at}) . '</td>'; | |
| 408 | $body .= '<td style="padding:6px 4px;color:#7e92b6">' . $lu . '</td>'; | |
| 409 | $body .= '<td style="padding:6px 4px">' . $st . '</td>'; | |
| 410 | if (!$k->{revoked_at}) { | |
| 411 | $body .= '<td style="padding:6px 4px;text-align:right"><form method="POST" style="display:inline" onsubmit="return confirm(\'Revoke this API key? Any integration using it will stop working.\')"><input type="hidden" name="act" value="revoke_api_key"><input type="hidden" name="key_id" value="' . $k->{id} . '"><button type="submit" style="background:transparent;color:#fca5a5;border:0;cursor:pointer;font-size:12px">Revoke</button></form></td>'; | |
| 412 | } else { | |
| 413 | $body .= '<td></td>'; | |
| 414 | } | |
| 415 | $body .= '</tr>'; | |
| 416 | } | |
| 417 | $body .= '</tbody></table>'; | |
| 418 | } else { | |
| 419 | $body .= '<div style="color:#7e92b6;font-size:13px;font-style:italic">No keys yet.</div>'; | |
| 420 | } | |
| 421 | } | |
| 422 | $body .= '</section>'; | |
| 423 | ||
| 424 | # Footer: links to other account-management pages | |
| 425 | $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">'; | |
| 426 | $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:10px">More account controls</div>'; | |
| 427 | $body .= ' <div style="display:flex;flex-wrap:wrap;gap:10px;font-size:13px">'; | |
| 428 | $body .= ' <a href="/sessions.cgi" style="color:#14b8a6;background:#1f2a4a;padding:8px 14px;border-radius:6px;text-decoration:none">Active sessions</a>'; | |
| 429 | $body .= ' <a href="/account_export.cgi" style="color:#14b8a6;background:#1f2a4a;padding:8px 14px;border-radius:6px;text-decoration:none">Download my data</a>'; | |
| 430 | $body .= ' <a href="/tax_settings.cgi" style="color:#14b8a6;background:#1f2a4a;padding:8px 14px;border-radius:6px;text-decoration:none">Tax / VAT settings</a>'; | |
| 431 | $body .= ' <a href="/billing.cgi" style="color:#14b8a6;background:#1f2a4a;padding:8px 14px;border-radius:6px;text-decoration:none">Billing & plan</a>'; | |
| 432 | $body .= ' <a href="/account_close.cgi" style="color:#fca5a5;background:#1f2a4a;padding:8px 14px;border-radius:6px;text-decoration:none">Close my account</a>'; | |
| 433 | $body .= ' </div>'; | |
| 434 | $body .= '</section>'; | |
| 435 | ||
| 436 | $body .= '</div>'; | |
| 437 | ||
| 438 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 439 | $wrap->render({ | |
| 440 | userinfo => $userinfo, | |
| 441 | page_key => 'profile', | |
| 442 | title => 'Profile', | |
| 443 | body => $body, | |
| 444 | }); | |
| 445 | exit; |