OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / app / Create.jsx

213 lines · jsx ref:
// Creating a new identity — the other half of the intro modal.
//
// ⛔ THE WORDING ON THIS SCREEN IS PART OF THE SECURITY MODEL, not copy — the same rule Pair.jsx states,
// for a sharper reason. Pairing is recoverable: the identity exists elsewhere and can be re-sent.
// Creating is not. Two of the things that happen when this button is tapped cannot be undone by anyone:
//
//   1. The recovery words are shown ONCE. They are the only thing that can rotate this identity's keys,
//      which is the only real revocation there is. Losing them is permanent, and no support channel,
//      backup or other device can reproduce them.
//   2. Registering the identity appends to a global, public, append-only log. There is no delete. A
//      test identity is forever, and this screen must not let anyone find that out afterwards.
//
// So the screen says both, before anything happens — and it does not offer a "skip" past the words.
//
// What it deliberately does NOT ask for: a handle, or which server you are on. Creation is P2P-only —
// the identity names nobody's infrastructure, and a domain handle arrives later, if you accept an
// invite. That is the whole point of the split (hyper-social #54 §8): creating an identity should not
// require choosing whose platform you are on.
import React, { useState } from 'react'
import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native'

export default function Create ({ client, onCreated, onCancel = null }) {
  const [pass, setPass] = useState('')
  const [confirm, setConfirm] = useState('')
  const [busy, setBusy] = useState(false)
  const [step, setStep] = useState(null)
  const [err, setErr] = useState(null)
  // The result, held here so the words can be shown BEFORE the node restarts. Restarting first would
  // tear this component down with the only copy of the mnemonic inside it.
  const [made, setMade] = useState(null)
  const [wroteItDown, setWroteItDown] = useState(false)

  const short = pass.length > 0 && pass.length < 8
  const mismatch = confirm.length > 0 && pass !== confirm
  const ready = pass.length >= 8 && pass === confirm && !busy

  const create = async () => {
    setBusy(true); setErr(null); setStep('creating your identity…')
    const onLog = (m) => { const s = m.msg || String(m); if (s.startsWith('mint:')) setStep(s.slice(5).trim()) }
    client.on('log', onLog)
    try {
      // The worklet owns all of it — keys, the vault, the directory call. This screen never touches a
      // secret except to display the words it is handed back.
      const r = await client.request('mint', { passphrase: pass })
      setMade(r)
      setStep(null)
    } catch (e) {
      setErr(e.message)
      setStep(null)
    } finally {
      if (typeof client.off === 'function') client.off('log', onLog)
      setBusy(false)
    }
  }

  // ── PHASE 2: the words ───────────────────────────────────────────────────────────────────────────
  // Reached only on success. There is no route back to the form from here, and no way past without the
  // acknowledgement — because this is the only time these words exist on screen.
  if (made) {
    const words = String(made.mnemonic || '').split(/\s+/).filter(Boolean)
    return (
      <ScrollView style={s.screen} contentContainerStyle={s.wrap}>
        <Text style={s.h1}>Write these down</Text>
        <View style={s.danger}>
          <Text style={s.dangerH}>This is shown once and never again</Text>
          <Text style={s.dangerB}>
            These {words.length} words are the only way to recover this identity or to remove a lost
            device from it. They are not stored anywhere you can read them back. If you lose them, nobody
            can help — not us, not another device.
          </Text>
          <Text style={s.dangerB}>Write them on paper. Do not screenshot them.</Text>
        </View>

        <View style={s.words}>
          {words.map((w, i) => (
            <Text key={i} style={s.word}><Text style={s.wordN}>{i + 1}. </Text>{w}</Text>
          ))}
        </View>

        <Text style={s.label}>Your identity</Text>
        <Text style={s.code}>{made.did}</Text>

        {/* HONEST ABOUT WHAT ACTUALLY HAPPENED. Each of these can fail independently, and a screen that
            reported success regardless would be the worst possible lie on this particular page. */}
        {made.sealed
          ? <Text style={s.dim}>A copy is sealed with your passphrase, so another device can recover it.</Text>
          : <Text style={s.err}>⚠ The sealed backup could not be written{made.sealError ? ' (' + made.sealError + ')' : ''} — the words above are your ONLY copy.</Text>}
        {made.submitted
          ? <Text style={s.dim}>Registered in the public directory, so others can find it.</Text>
          : <Text style={s.dim}>Not registered publicly yet{made.submitError ? ' (' + made.submitError + ')' : ''} — it works between your devices now, and can be registered later.</Text>}

        <Pressable style={s.checkRow} onPress={() => setWroteItDown((v) => !v)}>
          <View style={[s.box, wroteItDown && s.boxOn]}>{wroteItDown ? <Text style={s.tick}>✓</Text> : null}</View>
          <Text style={s.checkT}>I have written these words down</Text>
        </Pressable>

        <Pressable
          style={[s.btn, !wroteItDown && s.btnOff]}
          disabled={!wroteItDown}
          onPress={() => onCreated(made)}
        >
          <Text style={s.btnT}>Done — restart and start posting</Text>
        </Pressable>
      </ScrollView>
    )
  }

  // ── PHASE 1: what it costs, and the passphrase ───────────────────────────────────────────────────
  return (
    <ScrollView style={s.screen} contentContainerStyle={s.wrap}>
      <Text style={s.h1}>Create a new identity</Text>

      <Text style={s.body}>
        This makes a brand-new account that lives on this phone. It is yours — no server owns it, and
        nothing here asks whose platform you are joining.
      </Text>

      <View style={s.warn}>
        <Text style={s.warnH}>Two things you cannot undo</Text>
        <Text style={s.warnB}>
          You will be shown a list of recovery words <Text style={s.b}>once</Text>. They are the only way
          to recover this identity, or to cut off a device you lose. Nobody can reproduce them for you.
        </Text>
        <Text style={s.warnB}>
          Creating an identity writes a permanent public record. It cannot be deleted afterwards, so a
          throwaway account is not throwaway.
        </Text>
      </View>

      <Text style={s.label}>Passphrase</Text>
      <Text style={s.dim}>Used to seal a backup copy of your recovery words, so another device can
        restore them. It is never stored — if you forget it, the sealed copy is unopenable and only the
        written words remain.</Text>
      <TextInput
        style={s.input}
        value={pass}
        onChangeText={setPass}
        placeholder="at least 8 characters"
        secureTextEntry
        autoCapitalize="none"
        autoCorrect={false}
      />
      <TextInput
        style={s.input}
        value={confirm}
        onChangeText={setConfirm}
        placeholder="type it again"
        secureTextEntry
        autoCapitalize="none"
        autoCorrect={false}
      />
      {short ? <Text style={s.err}>Too short — at least 8 characters.</Text> : null}
      {mismatch ? <Text style={s.err}>The two do not match.</Text> : null}

      <Pressable style={[s.btn, !ready && s.btnOff]} disabled={!ready} onPress={create}>
        {busy ? <ActivityIndicator color="#fff" /> : <Text style={s.btnT}>Create my identity</Text>}
      </Pressable>

      {step ? <Text style={s.dim}>{step}</Text> : null}
      {err ? <Text style={s.err}>{err}</Text> : null}

      {onCancel ? (
        <Pressable style={s.skip} disabled={busy} onPress={onCancel}>
          <Text style={[s.skipT, busy && s.skipOff]}>Back</Text>
        </Pressable>
      ) : null}

      <Text style={s.foot}>
        Already have an identity on another device? Go back and pair instead — pairing copies the one you
        have, and does not create a second.
      </Text>
    </ScrollView>
  )
}

// ⛔ PAINTS ITS OWN BACKGROUND — see Pair.jsx for the full reason. AndroidManifest sets
// Theme.AppCompat.DayNight.NoActionBar, so an unpainted view sits on a DARK window in dark mode and
// every colour here was chosen for white. On THIS screen that is not merely ugly: the recovery words
// are shown exactly once, and a user who cannot read them has lost the identity permanently.
const s = StyleSheet.create({
  screen: { flex: 1, backgroundColor: '#fff' },
  wrap: { padding: 24, gap: 10, backgroundColor: '#fff' },
  h1: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
  body: { fontSize: 15, lineHeight: 21, color: '#333' },
  b: { fontWeight: '700' },
  warn: { backgroundColor: '#fff6e5', borderColor: '#e8c98a', borderWidth: 1, borderRadius: 8, padding: 14, gap: 8, marginVertical: 10 },
  warnH: { fontWeight: '700' },
  warnB: { fontSize: 14, lineHeight: 20, color: '#4a3c1a' },
  danger: { backgroundColor: '#fdecec', borderColor: '#e0a0a0', borderWidth: 1, borderRadius: 8, padding: 14, gap: 8, marginVertical: 10 },
  dangerH: { fontWeight: '700', color: '#7a1f1f' },
  dangerB: { fontSize: 14, lineHeight: 20, color: '#7a1f1f' },
  words: { backgroundColor: '#f7f7f7', borderRadius: 8, padding: 14, gap: 4, marginVertical: 6 },
  word: { fontFamily: 'monospace', fontSize: 15, lineHeight: 22, color: '#14171a' },
  wordN: { color: '#5b6b78' },
  label: { fontWeight: '600', marginTop: 10, color: '#14171a' },
  code: { fontFamily: 'monospace', backgroundColor: '#f2f2f2', padding: 10, borderRadius: 6, fontSize: 12, color: '#14171a' },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, fontSize: 14, marginTop: 6, color: '#14171a', backgroundColor: '#fff' },
  btn: { backgroundColor: '#111', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 16 },
  btnOff: { opacity: 0.4 },
  btnT: { color: '#fff', fontWeight: '600' },
  checkRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 },
  box: { width: 24, height: 24, borderWidth: 2, borderColor: '#888', borderRadius: 4, alignItems: 'center', justifyContent: 'center' },
  boxOn: { borderColor: '#111', backgroundColor: '#111' },
  tick: { color: '#fff', fontWeight: '700' },
  checkT: { fontSize: 15, flexShrink: 1, color: '#14171a' },
  dim: { color: '#5b6b78', fontSize: 13 },
  err: { color: '#b00', marginTop: 8 },
  skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 },
  skipT: { color: '#2563eb', fontSize: 16 },
  skipOff: { opacity: 0.4 },
  foot: { color: '#5b6b78', fontSize: 13, marginTop: 20, lineHeight: 19 }
})